diff --git a/cli/src/main/java/ca/weblite/jdeploy/packaging/PackageService.java b/cli/src/main/java/ca/weblite/jdeploy/packaging/PackageService.java index 42a2ad6f..28940027 100644 --- a/cli/src/main/java/ca/weblite/jdeploy/packaging/PackageService.java +++ b/cli/src/main/java/ca/weblite/jdeploy/packaging/PackageService.java @@ -4,6 +4,7 @@ import ca.weblite.jdeploy.JDeploy; import ca.weblite.jdeploy.app.AppInfo; import ca.weblite.jdeploy.app.JVMSpecification; +import ca.weblite.jdeploy.models.CommandSpecParser; import ca.weblite.jdeploy.app.permissions.PermissionRequest; import ca.weblite.jdeploy.app.permissions.PermissionRequestService; import ca.weblite.jdeploy.appbundler.*; @@ -896,6 +897,10 @@ private void loadAppInfo(PackagingContext context, AppInfo appInfo) throws IOExc appInfo.setJdeployRegistryUrl(packagingConfig.getJdeployRegistry()); } + // Parse CLI commands from jdeploy config for bundler use (e.g., embedded LaunchAgent plists) + JSONObject jdeployJson = new JSONObject(context.mj()); + appInfo.setCommands(CommandSpecParser.parseCommands(jdeployJson)); + String jarPath = context.getString("jar", null); if (jarPath != null) { JarFile jarFile = new JarFile(new File(context.directory, toNativePath(jarPath))); diff --git a/cli/src/main/java/ca/weblite/jdeploy/services/ProjectGenerator.java b/cli/src/main/java/ca/weblite/jdeploy/services/ProjectGenerator.java index 7995fa72..b5ea8dc7 100644 --- a/cli/src/main/java/ca/weblite/jdeploy/services/ProjectGenerator.java +++ b/cli/src/main/java/ca/weblite/jdeploy/services/ProjectGenerator.java @@ -13,6 +13,7 @@ import ca.weblite.tools.io.IOUtil; import org.apache.commons.io.FileUtils; import org.eclipse.jgit.api.errors.GitAPIException; +import org.json.JSONObject; import javax.inject.Inject; import javax.inject.Singleton; @@ -106,6 +107,7 @@ public File generate(ProjectGeneratorRequest request) throws Exception { } updateFilesInDirectory(projectDir, request); + updatePackageJsonWithGithubSettings(projectDir, request); if (mavenWrapperInjector.isMavenProject(projectDir.getPath())) { mavenWrapperInjector.installIntoProject(projectDir.getPath()); @@ -316,6 +318,35 @@ private void generateReleasesProject( setReleasesRepositoryInWorkflow(projectDirectory, releasesRepository); } + private void updatePackageJsonWithGithubSettings(File projectDir, ProjectGeneratorRequest request) throws IOException { + if (request.getGithubRepository() == null) { + return; + } + File packageJson = new File(projectDir, "package.json"); + if (!packageJson.exists()) { + return; + } + + String content = FileUtils.readFileToString(packageJson, "UTF-8"); + JSONObject json = new JSONObject(content); + + json.put("repository", GITHUB_URL + request.getGithubRepository()); + + JSONObject jdeploy = json.optJSONObject("jdeploy"); + if (jdeploy == null) { + jdeploy = new JSONObject(); + json.put("jdeploy", jdeploy); + } + + JSONObject github = new JSONObject(); + github.put("repository", request.getGithubRepository()); + github.put("releases_repository", getReleasesRepository(request)); + github.put("releases_url", getReleasesUrl(request)); + jdeploy.put("github", github); + + FileUtils.writeStringToFile(packageJson, json.toString(2), "UTF-8"); + } + private String getReleasesRepository(ProjectGeneratorRequest request) { return request.getGithubRepository() + "-releases"; } 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 2cf8293b..701a6538 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java @@ -191,6 +191,8 @@ private void loadNPMPackageInfo() throws IOException { appInfo().setDescription("Desktop application"); } + appInfo().setCommands(npmPackageVersion().getCommands()); + for (DocumentTypeAssociation documentTypeAssociation : npmPackageVersion().getDocumentTypeAssociations()) { if (documentTypeAssociation.isDirectory()) { // Handle directory association - check for default directory icon if none specified diff --git a/shared/src/main/java/ca/weblite/jdeploy/app/AppInfo.java b/shared/src/main/java/ca/weblite/jdeploy/app/AppInfo.java index 99f3ee05..6cc72a37 100644 --- a/shared/src/main/java/ca/weblite/jdeploy/app/AppInfo.java +++ b/shared/src/main/java/ca/weblite/jdeploy/app/AppInfo.java @@ -6,6 +6,7 @@ package ca.weblite.jdeploy.app; import ca.weblite.jdeploy.app.permissions.PermissionRequest; +import ca.weblite.jdeploy.models.CommandSpec; import ca.weblite.jdeploy.models.DocumentTypeAssociation; import ca.weblite.tools.platform.Platform; @@ -1433,6 +1434,7 @@ public AppInfo copy() { out.requireRunAsAdmin = requireRunAsAdmin; out.codeSignSettings = codeSignSettings; out.macAppBundleId = macAppBundleId; + out.commands = commands; if (permissions != null) { //out.permissions = new ArrayList<>(); for (Permission p : permissions) { @@ -1585,7 +1587,8 @@ private boolean equalsImpl(AppInfo o) { windowsJdeployHome, o.windowsJdeployHome, linuxJdeployHome, o.linuxJdeployHome, permissionRequests, o.permissionRequests, - directoryAssociation, o.directoryAssociation + directoryAssociation, o.directoryAssociation, + commands, o.commands }); } @@ -1629,6 +1632,16 @@ public static enum CodeSignSettings { private String macAppBundleId; + private List commands = Collections.emptyList(); + + public List getCommands() { + return commands; + } + + public void setCommands(List commands) { + this.commands = commands != null ? Collections.unmodifiableList(new ArrayList<>(commands)) : Collections.emptyList(); + } + public String getLauncherVersion() { return launcherVersion; } diff --git a/shared/src/main/java/ca/weblite/jdeploy/appbundler/AppDescription.java b/shared/src/main/java/ca/weblite/jdeploy/appbundler/AppDescription.java index 623e1b68..616e06bb 100644 --- a/shared/src/main/java/ca/weblite/jdeploy/appbundler/AppDescription.java +++ b/shared/src/main/java/ca/weblite/jdeploy/appbundler/AppDescription.java @@ -5,6 +5,8 @@ package ca.weblite.jdeploy.appbundler; +import ca.weblite.jdeploy.models.CommandSpec; + import java.io.File; import java.security.cert.Certificate; import java.util.*; @@ -68,6 +70,8 @@ public class AppDescription { */ private String initialAppVersion; + private List commands = Collections.emptyList(); + private Map macUsageDescriptions = new HashMap<>(); /** @@ -512,4 +516,12 @@ public String getInitialAppVersion() { public void setInitialAppVersion(String initialAppVersion) { this.initialAppVersion = initialAppVersion; } + + public List getCommands() { + return commands; + } + + public void setCommands(List commands) { + this.commands = commands != null ? Collections.unmodifiableList(new ArrayList<>(commands)) : Collections.emptyList(); + } } diff --git a/shared/src/main/java/ca/weblite/jdeploy/appbundler/Bundler.java b/shared/src/main/java/ca/weblite/jdeploy/appbundler/Bundler.java index 166ceeac..db91cf97 100644 --- a/shared/src/main/java/ca/weblite/jdeploy/appbundler/Bundler.java +++ b/shared/src/main/java/ca/weblite/jdeploy/appbundler/Bundler.java @@ -142,6 +142,7 @@ private static AppDescription createAppDescription(AppInfo appInfo, String url) setupFileAssociations(appInfo, app); setupUrlSchemes(appInfo, app); setupMacUsageDescriptions(appInfo, app); + app.setCommands(appInfo.getCommands()); return app; } 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 3d992d90..c2010a58 100644 --- a/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpec.java +++ b/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpec.java @@ -13,8 +13,9 @@ public class CommandSpec { private final String description; private final List args; private final List implementations; + private final Boolean embedPlist; - public CommandSpec(String name, String description, List args, List implementations) { + public CommandSpec(String name, String description, List args, List implementations, Boolean embedPlist) { if (name == null) { throw new IllegalArgumentException("Command name cannot be null"); } @@ -30,13 +31,18 @@ public CommandSpec(String name, String description, List args, List(implementations)); } + this.embedPlist = embedPlist; + } + + public CommandSpec(String name, String description, List args, List implementations) { + this(name, description, args, implementations, null); } /** * Constructor for backward compatibility (no implementations specified). */ public CommandSpec(String name, String description, List args) { - this(name, description, args, null); + this(name, description, args, null, null); } public String getName() { @@ -64,6 +70,18 @@ public boolean implements_(String implementation) { return implementations.contains(implementation); } + /** + * Returns whether this command should have an embedded LaunchAgent plist + * generated in the macOS app bundle. + * + * @return {@code Boolean.TRUE} to force embedding, {@code Boolean.FALSE} to force + * the launchctl fallback, or {@code null} to use the default heuristic + * (embed if all args are static). + */ + public Boolean getEmbedPlist() { + return embedPlist; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -73,12 +91,13 @@ public boolean equals(Object o) { return Objects.equals(name, that.name) && Objects.equals(description, that.description) && Objects.equals(args, that.args) && - Objects.equals(implementations, that.implementations); + Objects.equals(implementations, that.implementations) && + Objects.equals(embedPlist, that.embedPlist); } @Override public int hashCode() { - return Objects.hash(name, description, args, implementations); + return Objects.hash(name, description, args, implementations, embedPlist); } @Override @@ -88,6 +107,7 @@ public String toString() { ", description='" + description + '\'' + ", args=" + args + ", implementations=" + implementations + + ", embedPlist=" + embedPlist + '}'; } } 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 4b27258d..74cb1b81 100644 --- a/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpecParser.java +++ b/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpecParser.java @@ -131,7 +131,17 @@ public static List parseCommands(JSONObject jdeployConfig) { } } - result.add(new CommandSpec(name, description, args, implementations)); + Boolean embedPlist = null; + if (specObj.has("embedPlist")) { + Object embedObj = specObj.get("embedPlist"); + if (embedObj instanceof Boolean) { + embedPlist = (Boolean) embedObj; + } else if (embedObj != JSONObject.NULL) { + throw new IllegalArgumentException("Command '" + name + "': 'embedPlist' must be a boolean"); + } + } + + result.add(new CommandSpec(name, description, args, implementations, embedPlist)); } // sort by name for deterministic order diff --git a/shared/src/main/java/com/joshondesign/appbundler/mac/MacBundler.java b/shared/src/main/java/com/joshondesign/appbundler/mac/MacBundler.java index fc580769..75baf92e 100644 --- a/shared/src/main/java/com/joshondesign/appbundler/mac/MacBundler.java +++ b/shared/src/main/java/com/joshondesign/appbundler/mac/MacBundler.java @@ -2,6 +2,7 @@ import ca.weblite.jdeploy.appbundler.*; import ca.weblite.jdeploy.installer.CliInstallerConstants; +import ca.weblite.jdeploy.models.CommandSpec; import ca.weblite.tools.io.FileUtil; import ca.weblite.tools.io.IOUtil; import ca.weblite.tools.io.URLUtil; @@ -15,6 +16,8 @@ import java.io.*; import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Paths; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; @@ -96,6 +99,11 @@ public static BundlerResult start( // launcher. maybeCreateCliLauncher(bundlerSettings, contentsDir, stub_dest); + // Generate embedded LaunchAgent plists for service_controller commands. + // The native launcher checks for these at Contents/Library/LaunchAgents/.plist + // and uses SMAppService (macOS 13+) when present; otherwise falls back to launchctl. + maybeCreateLaunchAgentPlists(app, bundlerSettings, contentsDir); + SigningRequest signingRequest = new SigningRequest( app.getMacDeveloperID(), app.getMacCertificateName(), @@ -280,7 +288,150 @@ public static void maybeCreateCliLauncher(BundlerSettings bundlerSettings, File cliDest.setExecutable(true, false); } } - + + /** + * Generates embedded LaunchAgent plist files for service_controller commands + * whose arguments are fully static (no runtime placeholders). + * + *

The native launcher checks for these at + * {@code Contents/Library/LaunchAgents/.plist} and uses + * {@code SMAppService} (macOS 13+) when present; otherwise it falls back + * to the existing {@code launchctl} approach.

+ * + * @param app the app description containing commands and bundle ID + * @param settings the bundler settings (CLI commands must be enabled) + * @param contentsDir the Contents directory of the .app bundle + */ + static void maybeCreateLaunchAgentPlists(AppDescription app, BundlerSettings settings, File contentsDir) { + if (!settings.isCliCommandsEnabled()) { + return; + } + + String bundleId = app.getMacBundleId(); + if (bundleId == null || bundleId.isEmpty()) { + return; + } + + List serviceCommands = new ArrayList<>(); + for (CommandSpec cmd : app.getCommands()) { + if (cmd.implements_("service_controller") && canEmbedPlist(cmd)) { + serviceCommands.add(cmd); + } + } + + if (serviceCommands.isEmpty()) { + return; + } + + File launchAgentsDir = new File(contentsDir, "Library/LaunchAgents"); + if (!launchAgentsDir.mkdirs() && !launchAgentsDir.isDirectory()) { + System.err.println("Warning: Failed to create LaunchAgents directory: " + launchAgentsDir); + return; + } + + for (CommandSpec cmd : serviceCommands) { + String label = bundleId + "." + cmd.getName(); + File plistFile = new File(launchAgentsDir, cmd.getName() + ".plist"); + try { + writeLaunchAgentPlist(plistFile, label, cmd); + p("Generated LaunchAgent plist: " + plistFile.getName()); + } catch (IOException e) { + System.err.println("Warning: Failed to write LaunchAgent plist for command '" + + cmd.getName() + "': " + e.getMessage()); + } + } + } + + /** + * Determines whether a service_controller command's arguments are static + * enough to embed in a LaunchAgent plist at bundle time. + * + *

If the command has an explicit {@code embedPlist} flag, that takes + * precedence. Otherwise, the heuristic rejects any command whose args + * contain {@code $} (shell variable references) or {@code {{} + * (template expressions).

+ * + * @param cmd the command spec to evaluate + * @return true if an embedded plist should be generated + */ + static boolean canEmbedPlist(CommandSpec cmd) { + Boolean explicit = cmd.getEmbedPlist(); + if (explicit != null) { + return explicit; + } + for (String arg : cmd.getArgs()) { + if (arg.contains("$") || arg.contains("{{")) { + return false; + } + } + return true; + } + + /** + * Writes a LaunchAgent plist file for a service_controller command. + * + *

The plist uses {@code BundleProgram} (bundle-relative path) instead + * of {@code Program} (absolute path), as required by {@code SMAppService}.

+ * + * @param plistFile the destination file + * @param label the launchd service label (reverse-DNS) + * @param cmd the command spec + * @throws IOException if writing fails + */ + static void writeLaunchAgentPlist(File plistFile, String label, CommandSpec cmd) throws IOException { + String cliLauncher = "Contents/MacOS/" + CliInstallerConstants.CLI_LAUNCHER_NAME; + + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append("\n"); + xml.append("\n"); + xml.append("\n"); + + // Label + xml.append(" Label\n"); + xml.append(" ").append(escapeXml(label)).append("\n"); + + // BundleProgram — path relative to the app bundle root + xml.append(" BundleProgram\n"); + xml.append(" ").append(cliLauncher).append("\n"); + + // ProgramArguments + xml.append(" ProgramArguments\n"); + xml.append(" \n"); + xml.append(" ").append(cliLauncher).append("\n"); + xml.append(" ").append(escapeXml( + CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX + cmd.getName() + )).append("\n"); + for (String arg : cmd.getArgs()) { + xml.append(" ").append(escapeXml(arg)).append("\n"); + } + xml.append(" \n"); + + // Service behavior + xml.append(" RunAtLoad\n"); + xml.append(" \n"); + xml.append(" KeepAlive\n"); + xml.append(" \n"); + + xml.append("\n"); + xml.append("\n"); + + Files.write(plistFile.toPath(), xml.toString().getBytes(StandardCharsets.UTF_8)); + } + + /** + * Escapes special XML characters in a string for use in plist values. + */ + private static String escapeXml(String s) { + if (s == null) return ""; + return s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + private static void processAppXml(AppDescription app, File contentsDir) throws Exception { p("Processing the app.xml file"); XMLWriter out = new XMLWriter(new File(contentsDir, "app.xml")); diff --git a/shared/src/main/resources/com/joshondesign/appbundler/linux/arm64/Client4JLauncher b/shared/src/main/resources/com/joshondesign/appbundler/linux/arm64/Client4JLauncher index a1ad44fa..f4b79eb3 100755 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/linux/arm64/Client4JLauncher and b/shared/src/main/resources/com/joshondesign/appbundler/linux/arm64/Client4JLauncher differ diff --git a/shared/src/main/resources/com/joshondesign/appbundler/linux/x64/Client4JLauncher b/shared/src/main/resources/com/joshondesign/appbundler/linux/x64/Client4JLauncher index 04884482..bea9e470 100644 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/linux/x64/Client4JLauncher and b/shared/src/main/resources/com/joshondesign/appbundler/linux/x64/Client4JLauncher differ diff --git a/shared/src/main/resources/com/joshondesign/appbundler/mac/arm64/Client4JLauncher b/shared/src/main/resources/com/joshondesign/appbundler/mac/arm64/Client4JLauncher index 2a656ca2..656e62da 100644 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/mac/arm64/Client4JLauncher and b/shared/src/main/resources/com/joshondesign/appbundler/mac/arm64/Client4JLauncher differ diff --git a/shared/src/main/resources/com/joshondesign/appbundler/mac/x64/Client4JLauncher b/shared/src/main/resources/com/joshondesign/appbundler/mac/x64/Client4JLauncher index 752aa4c3..8e6a7ed5 100644 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/mac/x64/Client4JLauncher and b/shared/src/main/resources/com/joshondesign/appbundler/mac/x64/Client4JLauncher differ diff --git a/shared/src/main/resources/com/joshondesign/appbundler/win/arm64/Client4JLauncher.exe b/shared/src/main/resources/com/joshondesign/appbundler/win/arm64/Client4JLauncher.exe index d327189e..0d9c66f3 100644 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/win/arm64/Client4JLauncher.exe and b/shared/src/main/resources/com/joshondesign/appbundler/win/arm64/Client4JLauncher.exe differ diff --git a/shared/src/main/resources/com/joshondesign/appbundler/win/x64/Client4JLauncher.exe b/shared/src/main/resources/com/joshondesign/appbundler/win/x64/Client4JLauncher.exe index e0224cd7..10f67da9 100644 Binary files a/shared/src/main/resources/com/joshondesign/appbundler/win/x64/Client4JLauncher.exe and b/shared/src/main/resources/com/joshondesign/appbundler/win/x64/Client4JLauncher.exe differ diff --git a/shared/src/test/java/com/joshondesign/appbundler/mac/MacBundlerLaunchAgentPlistTest.java b/shared/src/test/java/com/joshondesign/appbundler/mac/MacBundlerLaunchAgentPlistTest.java new file mode 100644 index 00000000..a8b0cfc2 --- /dev/null +++ b/shared/src/test/java/com/joshondesign/appbundler/mac/MacBundlerLaunchAgentPlistTest.java @@ -0,0 +1,459 @@ +package com.joshondesign.appbundler.mac; + +import ca.weblite.jdeploy.appbundler.AppDescription; +import ca.weblite.jdeploy.appbundler.BundlerSettings; +import ca.weblite.jdeploy.installer.CliInstallerConstants; +import ca.weblite.jdeploy.models.CommandSpec; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the embedded LaunchAgent plist generation in MacBundler. + * + * Covers: + * - maybeCreateLaunchAgentPlists(): directory creation, file generation, guard conditions + * - canEmbedPlist(): static-args heuristic and explicit embedPlist flag + * - writeLaunchAgentPlist(): plist XML content, label, BundleProgram, ProgramArguments, extra args, XML escaping + */ +public class MacBundlerLaunchAgentPlistTest { + + private File tmpDir; + + @AfterEach + public void cleanup() throws IOException { + if (tmpDir != null && tmpDir.exists()) { + FileUtils.deleteDirectory(tmpDir); + } + } + + // ----------------------------------------------------------------------- + // canEmbedPlist tests + // ----------------------------------------------------------------------- + + @Test + public void canEmbedPlist_staticArgs_returnsTrue() { + CommandSpec cmd = serviceCommand("sync", Arrays.asList("-Xmx2g", "--verbose")); + assertTrue(MacBundler.canEmbedPlist(cmd)); + } + + @Test + public void canEmbedPlist_noArgs_returnsTrue() { + CommandSpec cmd = serviceCommand("sync", Collections.emptyList()); + assertTrue(MacBundler.canEmbedPlist(cmd)); + } + + @Test + public void canEmbedPlist_dollarSign_returnsFalse() { + CommandSpec cmd = serviceCommand("sync", Arrays.asList("--home=$HOME")); + assertFalse(MacBundler.canEmbedPlist(cmd)); + } + + @Test + public void canEmbedPlist_dollarBrace_returnsFalse() { + CommandSpec cmd = serviceCommand("sync", Arrays.asList("--home=${HOME}")); + assertFalse(MacBundler.canEmbedPlist(cmd)); + } + + @Test + public void canEmbedPlist_templateExpression_returnsFalse() { + CommandSpec cmd = serviceCommand("sync", Arrays.asList("--path={{DATA_DIR}}")); + assertFalse(MacBundler.canEmbedPlist(cmd)); + } + + @Test + public void canEmbedPlist_mixedArgs_returnsFalse() { + // One static, one dynamic — should reject + CommandSpec cmd = serviceCommand("sync", Arrays.asList("-Xmx2g", "--user=$USER")); + assertFalse(MacBundler.canEmbedPlist(cmd)); + } + + @Test + public void canEmbedPlist_explicitTrue_overridesHeuristic() { + // Has a $ in args, but embedPlist=true overrides + CommandSpec cmd = new CommandSpec("sync", "desc", + Arrays.asList("--home=$HOME"), + Collections.singletonList("service_controller"), + Boolean.TRUE); + assertTrue(MacBundler.canEmbedPlist(cmd)); + } + + @Test + public void canEmbedPlist_explicitFalse_overridesHeuristic() { + // All static args, but embedPlist=false forces fallback + CommandSpec cmd = new CommandSpec("sync", "desc", + Arrays.asList("-Xmx2g"), + Collections.singletonList("service_controller"), + Boolean.FALSE); + assertFalse(MacBundler.canEmbedPlist(cmd)); + } + + @Test + public void canEmbedPlist_explicitNull_usesHeuristic() { + CommandSpec cmd = new CommandSpec("sync", "desc", + Arrays.asList("-Xmx2g"), + Collections.singletonList("service_controller"), + null); + assertTrue(MacBundler.canEmbedPlist(cmd)); + } + + // ----------------------------------------------------------------------- + // writeLaunchAgentPlist tests + // ----------------------------------------------------------------------- + + @Test + public void writeLaunchAgentPlist_basicContent() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File plistFile = new File(tmpDir, "sync.plist"); + + CommandSpec cmd = serviceCommand("sync", Collections.emptyList()); + MacBundler.writeLaunchAgentPlist(plistFile, "com.example.app.sync", cmd); + + assertTrue(plistFile.exists(), "Plist file should be created"); + String content = FileUtils.readFileToString(plistFile, StandardCharsets.UTF_8); + + // XML header + assertTrue(content.startsWith(""), + "Should start with XML declaration"); + assertTrue(content.contains(""), "Should contain plist root"); + + // Label + assertTrue(content.contains("Label"), "Should contain Label key"); + assertTrue(content.contains("com.example.app.sync"), "Should contain label value"); + + // BundleProgram + String expectedPath = "Contents/MacOS/" + CliInstallerConstants.CLI_LAUNCHER_NAME; + assertTrue(content.contains("BundleProgram"), "Should contain BundleProgram key"); + assertTrue(content.contains("" + expectedPath + ""), + "BundleProgram should point to CLI launcher"); + + // ProgramArguments + assertTrue(content.contains("ProgramArguments"), "Should contain ProgramArguments key"); + assertTrue(content.contains("" + expectedPath + ""), + "ProgramArguments should include CLI launcher path"); + assertTrue(content.contains("--jdeploy:command=sync"), + "ProgramArguments should include --jdeploy:command="); + assertFalse(content.contains("--jdeploy:service"), + "ProgramArguments should NOT include --jdeploy:service (launchd runs the process directly)"); + assertFalse(content.contains("start"), + "ProgramArguments should NOT include start (launchd runs the process directly)"); + + // RunAtLoad and KeepAlive + assertTrue(content.contains("RunAtLoad"), "Should contain RunAtLoad"); + assertTrue(content.contains("KeepAlive"), "Should contain KeepAlive"); + + // Both should be true + int runAtLoadIdx = content.indexOf("RunAtLoad"); + int keepAliveIdx = content.indexOf("KeepAlive"); + String afterRunAtLoad = content.substring(runAtLoadIdx, keepAliveIdx); + assertTrue(afterRunAtLoad.contains(""), "RunAtLoad should be true"); + String afterKeepAlive = content.substring(keepAliveIdx); + assertTrue(afterKeepAlive.contains(""), "KeepAlive should be true"); + } + + @Test + public void writeLaunchAgentPlist_includesExtraArgs() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File plistFile = new File(tmpDir, "sync.plist"); + + CommandSpec cmd = serviceCommand("sync", Arrays.asList("-Xmx2g", "-Dfoo=bar")); + MacBundler.writeLaunchAgentPlist(plistFile, "com.example.app.sync", cmd); + + String content = FileUtils.readFileToString(plistFile, StandardCharsets.UTF_8); + assertTrue(content.contains("-Xmx2g"), "Should include first extra arg"); + assertTrue(content.contains("-Dfoo=bar"), "Should include second extra arg"); + + // Extra args should come after the --jdeploy:command arg + int commandIdx = content.indexOf("--jdeploy:command=sync"); + int xmxIdx = content.indexOf("-Xmx2g"); + int dfooIdx = content.indexOf("-Dfoo=bar"); + assertTrue(xmxIdx > commandIdx, "Extra args should appear after --jdeploy:command"); + assertTrue(dfooIdx > xmxIdx, "Args should preserve order"); + } + + @Test + public void writeLaunchAgentPlist_escapesXmlCharacters() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File plistFile = new File(tmpDir, "sync.plist"); + + CommandSpec cmd = serviceCommand("sync", Arrays.asList("-Dvalue=ad\"e'f")); + MacBundler.writeLaunchAgentPlist(plistFile, "com.example.&label", cmd); + + String content = FileUtils.readFileToString(plistFile, StandardCharsets.UTF_8); + assertTrue(content.contains("com.example.<special>&label"), + "Label should have XML-escaped characters"); + assertTrue(content.contains("-Dvalue=a<b&c>d"e'f"), + "Args should have XML-escaped characters"); + } + + @Test + public void writeLaunchAgentPlist_noExtraArgs() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File plistFile = new File(tmpDir, "ctl.plist"); + + CommandSpec cmd = serviceCommand("ctl", Collections.emptyList()); + MacBundler.writeLaunchAgentPlist(plistFile, "com.example.app.ctl", cmd); + + String content = FileUtils.readFileToString(plistFile, StandardCharsets.UTF_8); + + // Count tags inside the — should be exactly 2: + // cli launcher, --jdeploy:command=ctl + int arrayStart = content.indexOf(""); + int arrayEnd = content.indexOf(""); + String arrayContent = content.substring(arrayStart, arrayEnd); + int stringCount = countOccurrences(arrayContent, ""); + assertEquals(2, stringCount, + "ProgramArguments should have exactly 2 entries when no extra args"); + } + + // ----------------------------------------------------------------------- + // maybeCreateLaunchAgentPlists integration tests + // ----------------------------------------------------------------------- + + @Test + public void maybeCreate_generatesPlists_forServiceControllerCommands() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File contentsDir = new File(tmpDir, "TestApp.app/Contents"); + assertTrue(contentsDir.mkdirs()); + + BundlerSettings settings = new BundlerSettings(); + settings.setCliCommandsEnabled(true); + + AppDescription app = new AppDescription(); + app.setName("TestApp"); + app.setMacBundleId("com.example.testapp"); + app.setCommands(Arrays.asList( + serviceCommand("sync-service", Collections.emptyList()), + serviceCommand("bg-worker", Arrays.asList("-Xmx512m")) + )); + + MacBundler.maybeCreateLaunchAgentPlists(app, settings, contentsDir); + + File launchAgentsDir = new File(contentsDir, "Library/LaunchAgents"); + assertTrue(launchAgentsDir.isDirectory(), "LaunchAgents directory should exist"); + + File syncPlist = new File(launchAgentsDir, "sync-service.plist"); + File bgPlist = new File(launchAgentsDir, "bg-worker.plist"); + assertTrue(syncPlist.exists(), "sync-service.plist should be created"); + assertTrue(bgPlist.exists(), "bg-worker.plist should be created"); + + // Verify label includes bundle ID + String syncContent = FileUtils.readFileToString(syncPlist, StandardCharsets.UTF_8); + assertTrue(syncContent.contains("com.example.testapp.sync-service"), + "Label should be bundleId.commandName"); + + String bgContent = FileUtils.readFileToString(bgPlist, StandardCharsets.UTF_8); + assertTrue(bgContent.contains("com.example.testapp.bg-worker"), + "Label should be bundleId.commandName"); + assertTrue(bgContent.contains("-Xmx512m"), + "Extra args should be included"); + } + + @Test + public void maybeCreate_skipsNonServiceControllerCommands() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File contentsDir = new File(tmpDir, "TestApp.app/Contents"); + assertTrue(contentsDir.mkdirs()); + + BundlerSettings settings = new BundlerSettings(); + settings.setCliCommandsEnabled(true); + + AppDescription app = new AppDescription(); + app.setName("TestApp"); + app.setMacBundleId("com.example.testapp"); + app.setCommands(Arrays.asList( + // updater only — no service_controller + new CommandSpec("update-tool", "Updater", Collections.emptyList(), + Collections.singletonList("updater")), + // launcher only + new CommandSpec("gui-launcher", "Launcher", Collections.emptyList(), + Collections.singletonList("launcher")) + )); + + MacBundler.maybeCreateLaunchAgentPlists(app, settings, contentsDir); + + File launchAgentsDir = new File(contentsDir, "Library/LaunchAgents"); + assertFalse(launchAgentsDir.exists(), + "LaunchAgents directory should not be created when no service_controller commands exist"); + } + + @Test + public void maybeCreate_skipsDynamicArgCommands() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File contentsDir = new File(tmpDir, "TestApp.app/Contents"); + assertTrue(contentsDir.mkdirs()); + + BundlerSettings settings = new BundlerSettings(); + settings.setCliCommandsEnabled(true); + + AppDescription app = new AppDescription(); + app.setName("TestApp"); + app.setMacBundleId("com.example.testapp"); + app.setCommands(Arrays.asList( + // Dynamic — uses $HOME + serviceCommand("dynamic-svc", Arrays.asList("--dir=$HOME/data")), + // Static — should still be generated + serviceCommand("static-svc", Arrays.asList("-Xmx1g")) + )); + + MacBundler.maybeCreateLaunchAgentPlists(app, settings, contentsDir); + + File launchAgentsDir = new File(contentsDir, "Library/LaunchAgents"); + assertTrue(launchAgentsDir.isDirectory(), "LaunchAgents directory should exist"); + + assertFalse(new File(launchAgentsDir, "dynamic-svc.plist").exists(), + "Dynamic-arg command should NOT get a plist"); + assertTrue(new File(launchAgentsDir, "static-svc.plist").exists(), + "Static-arg command should get a plist"); + } + + @Test + public void maybeCreate_skipsWhenCliCommandsDisabled() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File contentsDir = new File(tmpDir, "TestApp.app/Contents"); + assertTrue(contentsDir.mkdirs()); + + BundlerSettings settings = new BundlerSettings(); + // CLI commands NOT enabled (default) + + AppDescription app = new AppDescription(); + app.setName("TestApp"); + app.setMacBundleId("com.example.testapp"); + app.setCommands(Collections.singletonList( + serviceCommand("sync", Collections.emptyList()) + )); + + MacBundler.maybeCreateLaunchAgentPlists(app, settings, contentsDir); + + File launchAgentsDir = new File(contentsDir, "Library/LaunchAgents"); + assertFalse(launchAgentsDir.exists(), + "LaunchAgents directory should not be created when CLI commands are disabled"); + } + + @Test + public void maybeCreate_skipsWhenNoBundleId() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File contentsDir = new File(tmpDir, "TestApp.app/Contents"); + assertTrue(contentsDir.mkdirs()); + + BundlerSettings settings = new BundlerSettings(); + settings.setCliCommandsEnabled(true); + + AppDescription app = new AppDescription(); + app.setName("TestApp"); + // No bundle ID set + app.setCommands(Collections.singletonList( + serviceCommand("sync", Collections.emptyList()) + )); + + MacBundler.maybeCreateLaunchAgentPlists(app, settings, contentsDir); + + File launchAgentsDir = new File(contentsDir, "Library/LaunchAgents"); + assertFalse(launchAgentsDir.exists(), + "LaunchAgents directory should not be created when bundle ID is null"); + } + + @Test + public void maybeCreate_skipsWhenNoCommands() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File contentsDir = new File(tmpDir, "TestApp.app/Contents"); + assertTrue(contentsDir.mkdirs()); + + BundlerSettings settings = new BundlerSettings(); + settings.setCliCommandsEnabled(true); + + AppDescription app = new AppDescription(); + app.setName("TestApp"); + app.setMacBundleId("com.example.testapp"); + // No commands set (empty default) + + MacBundler.maybeCreateLaunchAgentPlists(app, settings, contentsDir); + + File launchAgentsDir = new File(contentsDir, "Library/LaunchAgents"); + assertFalse(launchAgentsDir.exists(), + "LaunchAgents directory should not be created when no commands exist"); + } + + @Test + public void maybeCreate_respectsEmbedPlistFalse() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File contentsDir = new File(tmpDir, "TestApp.app/Contents"); + assertTrue(contentsDir.mkdirs()); + + BundlerSettings settings = new BundlerSettings(); + settings.setCliCommandsEnabled(true); + + AppDescription app = new AppDescription(); + app.setName("TestApp"); + app.setMacBundleId("com.example.testapp"); + app.setCommands(Collections.singletonList( + new CommandSpec("sync", "desc", Collections.emptyList(), + Collections.singletonList("service_controller"), + Boolean.FALSE) // Explicit opt-out + )); + + MacBundler.maybeCreateLaunchAgentPlists(app, settings, contentsDir); + + File launchAgentsDir = new File(contentsDir, "Library/LaunchAgents"); + assertFalse(launchAgentsDir.exists(), + "LaunchAgents directory should not be created when embedPlist=false"); + } + + @Test + public void maybeCreate_respectsEmbedPlistTrue_despiteDynamicArgs() throws Exception { + tmpDir = Files.createTempDirectory("plist-test").toFile(); + File contentsDir = new File(tmpDir, "TestApp.app/Contents"); + assertTrue(contentsDir.mkdirs()); + + BundlerSettings settings = new BundlerSettings(); + settings.setCliCommandsEnabled(true); + + AppDescription app = new AppDescription(); + app.setName("TestApp"); + app.setMacBundleId("com.example.testapp"); + app.setCommands(Collections.singletonList( + new CommandSpec("sync", "desc", + Arrays.asList("--path=$HOME/data"), + Collections.singletonList("service_controller"), + Boolean.TRUE) // Force embed despite $ in args + )); + + MacBundler.maybeCreateLaunchAgentPlists(app, settings, contentsDir); + + File syncPlist = new File(contentsDir, "Library/LaunchAgents/sync.plist"); + assertTrue(syncPlist.exists(), + "Plist should be created when embedPlist=true overrides dynamic arg heuristic"); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Creates a CommandSpec that implements service_controller with the given args. + */ + private static CommandSpec serviceCommand(String name, List args) { + return new CommandSpec(name, null, args, Collections.singletonList("service_controller")); + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + int idx = 0; + while ((idx = haystack.indexOf(needle, idx)) != -1) { + count++; + idx += needle.length(); + } + return count; + } +}