From 26831492bcc0cc2a85db0da7182ba4046e1173fc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:54:22 +0300 Subject: [PATCH 1/5] Fix legacy cn1lib installation in Settings --- .../com/codename1/maven/OpenSettingsMojo.java | 1 + .../codename1/maven/OpenSettingsMojoTest.java | 16 ++ .../settings/CodenameOneSettings.java | 93 +++++++--- .../extensions/LegacyMavenInstaller.java | 33 ++++ .../settings/extensions/PomEditor.java | 2 +- .../settings/project/ProjectBinding.java | 6 + .../com/codename1/settings/PomEditorTest.java | 8 + .../settings/CodenameOneSettingsStub.java | 1 + .../settings/MavenLibraryInstaller.java | 175 ++++++++++++++++++ .../settings/MavenLibraryInstallerTest.java | 162 ++++++++++++++++ 10 files changed, 475 insertions(+), 22 deletions(-) create mode 100644 scripts/settings/common/src/main/java/com/codename1/settings/extensions/LegacyMavenInstaller.java create mode 100644 scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java create mode 100644 scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java index 5cb4fd24bb8..0a9d9dcc754 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java @@ -159,6 +159,7 @@ void writeBinding(File inputFile, File projectDir) throws MojoExecutionException // file does not, so a tool reading the file looked at a different // class than the build and reported the annotations on the // selected one as absent. + + bindingValue("mavenHome", System.getProperty("maven.home")) + bindingValue("mainName", effective("codename1.mainName")) + bindingValue("packageName", effective("codename1.packageName")); try { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java index 5e54f8eadd2..ac13f3bff01 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java @@ -44,6 +44,22 @@ public class OpenSettingsMojoTest { @Rule public TemporaryFolder tmp = new TemporaryFolder(); + @Test + public void forwardsMavenHomeToSettingsBinding() throws Exception { + String previous = System.getProperty("maven.home"); + try { + System.setProperty("maven.home", "/tools/maven with spaces"); + File common = tmp.newFolder("binding-common"); + File input = tmp.newFile("binding.input"); + new OpenSettingsMojo().writeBinding(input, common); + assertTrue(new String(Files.readAllBytes(input.toPath()), StandardCharsets.UTF_8) + .contains("mavenHome=/tools/maven with spaces\n")); + } finally { + if (previous == null) System.clearProperty("maven.home"); + else System.setProperty("maven.home", previous); + } + } + @Test public void extractsPackagedIconForDesktopLaunchers() throws Exception { File jar = jarWithIcon("settings.jar"); diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java b/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java index 9e18c812032..55c5046a685 100644 --- a/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java +++ b/scripts/settings/common/src/main/java/com/codename1/settings/CodenameOneSettings.java @@ -36,6 +36,7 @@ import com.codename1.settings.extensions.ExtensionCatalogMerger; import com.codename1.settings.extensions.MavenCentralSearch; import com.codename1.settings.extensions.MavenDependency; +import com.codename1.settings.extensions.LegacyMavenInstaller; import com.codename1.settings.extensions.DependencyEditor; import com.codename1.settings.hints.BuildHintCatalog; import com.codename1.settings.hints.BuildHintMetadata; @@ -117,6 +118,13 @@ public enum Section { BASIC, BUILD_HINTS, EXTENSIONS, ADVANCED } private final Map expandedExtensions = new LinkedHashMap(); private Button toolbarMenuButton; private static CodenameOneSettings activeSettings; + private static LegacyMavenInstaller legacyMavenInstaller; + private boolean legacyInstallRunning; + + /// Supplied by the desktop launcher; Maven execution is unavailable on mobile ports. + public static void setLegacyMavenInstaller(LegacyMavenInstaller installer) { + legacyMavenInstaller = installer; + } public static void adjustActiveFontSizeForDesktopShortcut(int deltaPx) { CodenameOneSettings s = activeSettings; @@ -1513,6 +1521,7 @@ private Component extensionRow(ExtensionDescriptor descriptor) { boolean installed = isDependencyInstalled(descriptor.dependency()); Button add = new Button(installed ? "Installed ✓" : "Download", installed ? "SettingsSave" : "SettingsExtensionPrimary"); + add.setEnabled(!legacyInstallRunning); add.addActionListener(e -> { if (installed) { offerUninstall(descriptor); @@ -1529,6 +1538,7 @@ private Component extensionRow(ExtensionDescriptor descriptor) { boolean installed = isLegacyCn1LibInstalled(descriptor); Button install = new Button(installed ? "Installed ✓" : "Install", installed ? "SettingsSave" : "SettingsExtensionPrimary"); + install.setEnabled(!legacyInstallRunning); install.addActionListener(e -> { if (installed) { offerUninstall(descriptor); @@ -1777,44 +1787,77 @@ private void installLegacyCn1Lib(ExtensionDescriptor descriptor) { ToastBar.showErrorMessage("Gradle projects do not support legacy cn1lib files."); return; } - if (!confirmCompatibility(descriptor)) { + if (legacyInstallRunning || !confirmCompatibility(descriptor)) { + return; + } + if (binding.isMaven() && legacyMavenInstaller == null) { + ToastBar.showErrorMessage("Maven library installation requires the desktop Settings launcher."); return; } + legacyInstallRunning = true; + renderPage(); + ToastBar.showInfoMessage("Installing " + descriptor.fileName() + "…"); Display.getInstance().startThread(() -> { try { - ConnectionRequest req = new ConnectionRequest(); - req.setUrl("https://www.codenameone.com/files/cn1libs/" + encodeUrlPath(descriptor.fileName())); - req.setPost(false); - NetworkManager.getInstance().addToQueueAndWait(req); - if (req.getResponseCode() >= 400 || req.getResponseData() == null) { - throw new java.io.IOException("Download failed with HTTP " + req.getResponseCode()); - } + System.out.println("Installing legacy library " + descriptor.fileName()); String dir = legacyCn1LibDir(); FileSystemStorage fs = FileSystemStorage.getInstance(); - fs.mkdir(ProjectIO.fsUrl(dir)); String dest = dir + "/" + descriptor.fileName(); - OutputStream out = null; - try { - out = fs.openOutputStream(ProjectIO.fsUrl(dest)); - out.write(req.getResponseData()); - out.flush(); - } finally { - Util.cleanup(out); + // A removed dependency can reuse its wrapper without replacing user-edited files. + boolean hasWrapper = binding.isMaven() + && legacyMavenInstaller.dependency(binding, descriptor.fileName()) != null; + if (!hasWrapper) { + ConnectionRequest req = new ConnectionRequest(); + req.setUrl("https://www.codenameone.com/files/cn1libs/" + encodeUrlPath(descriptor.fileName())); + req.setPost(false); + NetworkManager.getInstance().addToQueueAndWait(req); + if (req.getResponseCode() >= 400 || req.getResponseData() == null) { + throw new java.io.IOException("Download failed with HTTP " + req.getResponseCode()); + } + fs.mkdir(ProjectIO.fsUrl(dir)); + OutputStream out = null; + try { + out = fs.openOutputStream(ProjectIO.fsUrl(dest)); + out.write(req.getResponseData()); + out.flush(); + } finally { + Util.cleanup(out); + } + } + if (binding.isMaven()) { + legacyMavenInstaller.install(binding, dest); } + System.out.println("Installed legacy library " + descriptor.fileName()); CN.callSerially(() -> { ToastBar.showMessage("Installed " + descriptor.fileName() - + " into cn1libs/", FontImage.MATERIAL_CHECK); + + (binding.isMaven() ? ". Reload the Maven project in your IDE." : " into lib/"), FontImage.MATERIAL_CHECK); renderPage(); animatePage(); }); } catch (Exception ex) { Log.e(ex); + ex.printStackTrace(); // The desktop launcher redirects stderr to settings.log. CN.callSerially(() -> ToastBar.showErrorMessage("Failed to install cn1lib: " + ex.getMessage())); + } finally { + CN.callSerially(() -> { + legacyInstallRunning = false; + renderPage(); + }); } }, "SettingsLegacyCn1LibInstall").start(); } private boolean isLegacyCn1LibInstalled(ExtensionDescriptor descriptor) { + if (binding.isMaven()) { + try { + MavenDependency dependency = legacyMavenInstaller == null ? null + : legacyMavenInstaller.dependency(binding, descriptor.fileName()); + return dependency != null && isDependencyInstalled(dependency); + } catch (Exception ex) { + Log.e(ex); + return false; + } + } return descriptor.fileName().length() > 0 && legacyCn1LibDir() != null && FileSystemStorage.getInstance().exists(ProjectIO.fsUrl(legacyCn1LibPath(descriptor))); } @@ -1831,6 +1874,17 @@ private void offerUninstall(ExtensionDescriptor descriptor) { } if (descriptor.dependency() != null) { removeDependency(descriptor.dependency()); + } else if (binding.isMaven()) { + try { + MavenDependency dependency = legacyMavenInstaller.dependency(binding, descriptor.fileName()); + if (dependency != null) { + // Keep the wrapper so reinstalling never destroys its local modifications. + removeDependency(dependency); + } + } catch (Exception ex) { + Log.e(ex); + ToastBar.showErrorMessage("Failed to remove library: " + ex.getMessage()); + } } else { String path = legacyCn1LibPath(descriptor); FileSystemStorage fs = FileSystemStorage.getInstance(); @@ -1891,10 +1945,7 @@ private String legacyCn1LibDir() { if (ProjectBinding.BUILD_ANT.equals(binding.buildSystem())) { return binding.projectDir() + "/lib"; } - if (binding.multimoduleRoot() != null && binding.multimoduleRoot().length() > 0) { - return binding.multimoduleRoot() + "/cn1libs"; - } - String projectDir = binding.projectDir(); + String projectDir = binding.projectDir() == null ? null : binding.projectDir().replace('\\', '/'); if (projectDir != null && projectDir.endsWith("/common")) { return projectDir.substring(0, projectDir.length() - "/common".length()) + "/cn1libs"; } diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/LegacyMavenInstaller.java b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/LegacyMavenInstaller.java new file mode 100644 index 00000000000..5797d9ef4f7 --- /dev/null +++ b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/LegacyMavenInstaller.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.settings.extensions; + +import com.codename1.settings.project.ProjectBinding; + +/// Desktop integration for legacy libraries that must be converted into Maven modules. +public interface LegacyMavenInstaller { + void install(ProjectBinding binding, String libraryPath) throws Exception; + + /// Coordinates from the generated wrapper, or null before conversion. + MavenDependency dependency(ProjectBinding binding, String fileName) throws Exception; +} diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java index 709da7e7de1..a077701483b 100644 --- a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java +++ b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java @@ -124,7 +124,7 @@ public static String removeDependency(String pom, MavenDependency dependency) { String block = pom.substring(start, end); if (matches(block, dependency)) { int lineStart = start; - while (lineStart > 0 && pom.charAt(lineStart - 1) != '\n') { + while (lineStart > 0 && (pom.charAt(lineStart - 1) == ' ' || pom.charAt(lineStart - 1) == '\t')) { lineStart--; } int lineEnd = end; diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java b/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java index 411e9cae428..7154d044f80 100644 --- a/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java +++ b/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java @@ -29,6 +29,7 @@ public final class ProjectBinding { public static final String BUILD_MAVEN = "MAVEN"; public static final String BUILD_GRADLE = "GRADLE"; + private String mavenHome; private String projectDir; private String settings; private String pom; @@ -42,6 +43,10 @@ public final class ProjectBinding { private String mainName; private String packageName; + public String mavenHome() { + return mavenHome; + } + public String projectDir() { return projectDir; } @@ -168,6 +173,7 @@ public static ProjectBinding parse(String content) { String key = trimmed.substring(0, eq).trim(); String val = trimmed.substring(eq + 1).trim(); switch (key) { + case "mavenHome" -> b.mavenHome = val; case "projectDir" -> b.projectDir = val; case "settings" -> b.settings = val; case "pom" -> b.pom = val; diff --git a/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java b/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java index e82b16e0ed2..181d88692c5 100644 --- a/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java +++ b/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java @@ -9,6 +9,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class PomEditorTest { + @Test + public void removingInstalledLegacyDependencyPreservesCompactPom() { + String pom = ""; + MavenDependency dependency = new MavenDependency("example", "app-CN1JSON", "1", "pom"); + String installed = PomEditor.addDependency(pom, dependency); + assertEquals(pom, PomEditor.removeDependency(installed, dependency)); + } + @Test public void addsDependencyToExistingDependenciesBlock() { String pom = "\n \n \n\n"; diff --git a/scripts/settings/javase/src/desktop/java/com/codename1/settings/CodenameOneSettingsStub.java b/scripts/settings/javase/src/desktop/java/com/codename1/settings/CodenameOneSettingsStub.java index f09c786362e..47f6bbca2d5 100644 --- a/scripts/settings/javase/src/desktop/java/com/codename1/settings/CodenameOneSettingsStub.java +++ b/scripts/settings/javase/src/desktop/java/com/codename1/settings/CodenameOneSettingsStub.java @@ -74,6 +74,7 @@ public class CodenameOneSettingsStub implements Runnable, WindowListener { private CodenameOneSettings mainApp; public static void main(String[] args) { + CodenameOneSettings.setLegacyMavenInstaller(new MavenLibraryInstaller()); configureDesktopAppIdentity(); if (System.getProperty("settings.version") == null) { System.setProperty("settings.version", APP_VERSION); diff --git a/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java b/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java new file mode 100644 index 00000000000..3832caf2542 --- /dev/null +++ b/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.settings; + +import com.codename1.settings.extensions.LegacyMavenInstaller; +import com.codename1.settings.extensions.MavenDependency; +import com.codename1.settings.extensions.PomEditor; +import com.codename1.settings.project.ProjectBinding; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +/// Runs the existing Maven converter off the EDT instead of treating a downloaded +/// archive as an installed dependency. Uses Java directly to avoid shell quoting +/// of Maven batch files and project/library paths on Windows. +public class MavenLibraryInstaller implements LegacyMavenInstaller { + @Override + public void install(ProjectBinding binding, String libraryPath) throws Exception { + File library = new File(libraryPath); + File common = new File(binding.projectDir()); + MavenDependency dependency = dependency(binding, library.getName()); + if (dependency == null) { + if (!library.isFile()) { + throw new IOException("Library download is missing: " + library); + } + runMaven(command(binding, library), common); + dependency = dependency(binding, library.getName()); + if (dependency == null) { + throw new IOException("Maven did not create the library wrapper. See settings.log."); + } + } + // The old converter requires an INJECT DEPENDENCIES comment. Projects + // without that marker still need a real dependency, not a success toast + // alongside a warning in the Maven output. Also supports reinstalling a + // dependency removed in Settings without overwriting its wrapper. + File pom = new File(binding.pom()); + String original = Files.readString(pom.toPath(), StandardCharsets.UTF_8); + String editable = PomEditor.containsDependency(original, dependency) ? original + : original.replaceAll("", ""); + String updated = PomEditor.addDependency(editable, dependency); + if (!original.equals(updated)) { + Files.writeString(pom.toPath(), updated, StandardCharsets.UTF_8); + } + } + + @Override + public MavenDependency dependency(ProjectBinding binding, String fileName) throws Exception { + if (fileName == null || !fileName.endsWith(".cn1lib") || fileName.contains("/") + || fileName.contains("\\") || fileName.length() == ".cn1lib".length()) { + throw new IOException("Invalid cn1lib filename: " + fileName); + } + // This is the layout required by cn1:install-cn1lib, including when + // Maven was launched with a larger multi-module reactor as its root. + File common = new File(binding.projectDir()).getCanonicalFile(); + if (!"common".equals(common.getName())) { + throw new IOException("Legacy Maven libraries require a common/ project directory."); + } + File wrapper = new File(common.getParentFile(), "cn1libs/" + + fileName.substring(0, fileName.length() - ".cn1lib".length()) + "/pom.xml"); + if (!wrapper.isFile()) { + return null; + } + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + Element project = factory.newDocumentBuilder().parse(wrapper).getDocumentElement(); + MavenDependency dependency = new MavenDependency(value(project, "groupId"), + value(project, "artifactId"), value(project, "version"), "pom"); + if (!dependency.isValid()) { + throw new IOException("Library wrapper has incomplete Maven coordinates: " + wrapper); + } + return dependency; + } + + private static String value(Element project, String name) { + // Only direct children: the generated wrapper also contains its parent's + // coordinates and dependencies with unrelated artifact IDs. + for (Node node = project.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element && name.equals(node.getNodeName())) { + return node.getTextContent().trim(); + } + } + return ""; + } + + List command(ProjectBinding binding, File library) throws IOException { + File home = mavenHome(binding); + File[] boot = new File(home, "boot").listFiles((dir, name) -> + name.startsWith("plexus-classworlds-") && name.endsWith(".jar")); + if (boot == null || boot.length != 1) { + throw new IOException("Cannot find Maven's launcher in " + home); + } + boolean windows = System.getProperty("os.name", "").startsWith("Windows"); + String java = new File(System.getProperty("java.home"), "bin/java" + (windows ? ".exe" : "")).getPath(); + File root = new File(binding.projectDir()).getCanonicalFile().getParentFile(); + return new ArrayList<>(Arrays.asList(java, "-Duser.home=" + System.getProperty("user.home"), + "-Dmaven.home=" + home, + "-Dmaven.multiModuleProjectDirectory=" + root, + "-Dclassworlds.conf=" + new File(home, "bin/m2.conf"), + "-cp", boot[0].getPath(), "org.codehaus.plexus.classworlds.launcher.Launcher", + "--batch-mode", "--non-recursive", "cn1:install-cn1lib", + "-Dfile=" + library.getAbsolutePath())); + } + + private File mavenHome(ProjectBinding binding) throws IOException { + List candidates = new ArrayList<>(Arrays.asList(binding.mavenHome(), + System.getProperty("maven.home"), System.getenv("MAVEN_HOME"), System.getenv("M2_HOME"))); + String path = System.getenv("PATH"); + if (path != null) { + for (String directory : path.split(java.util.regex.Pattern.quote(File.pathSeparator))) { + for (String name : new String[]{"mvn", "mvn.cmd"}) { + File executable = new File(directory, name); + if (executable.isFile()) { + candidates.add(executable.getCanonicalFile().getParentFile().getParent()); + } + } + } + } + for (String candidate : candidates) { + if (candidate != null && new File(candidate, "bin/m2.conf").isFile()) { + return new File(candidate).getCanonicalFile(); + } + } + throw new IOException("Cannot locate Maven. Launch Settings with mvn cn1:settings, or set MAVEN_HOME."); + } + + void runMaven(List command, File common) throws IOException, InterruptedException { + System.out.println("Running cn1:install-cn1lib in " + common); + Process process = new ProcessBuilder(command).directory(common) + .redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.INHERIT).start(); + process.getOutputStream().close(); + try { + if (!process.waitFor(5, TimeUnit.MINUTES)) { + throw new IOException("Maven library installation timed out. See settings.log."); + } + if (process.exitValue() != 0) { + throw new IOException("Maven library installation failed (exit " + process.exitValue() + + "). See settings.log."); + } + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + } + } + } +} diff --git a/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java b/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java new file mode 100644 index 00000000000..f28fbe3097f --- /dev/null +++ b/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.settings; + +import com.codename1.settings.extensions.MavenDependency; +import com.codename1.settings.extensions.PomEditor; +import com.codename1.settings.project.ProjectBinding; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class MavenLibraryInstallerTest { + @TempDir Path root; + + private ProjectBinding project() throws Exception { + Path common = Files.createDirectories(root.resolve("app with spaces/common")); + Files.writeString(common.resolve("pom.xml"), + "4.0.0"); + Path home = Files.createDirectories(root.resolve("maven with spaces")); + Files.createDirectories(home.resolve("bin")); + Files.createDirectories(home.resolve("boot")); + Files.writeString(home.resolve("bin/m2.conf"), ""); + Files.writeString(home.resolve("boot/plexus-classworlds-test.jar"), ""); + return ProjectBinding.parse("projectDir=" + common + "\npom=" + common.resolve("pom.xml") + + "\nmultimoduleRoot=" + root + "\nmavenHome=" + home); + } + + private Path wrapper(ProjectBinding binding) throws Exception { + Path wrapper = Path.of(binding.projectDir()).getParent().resolve("cn1libs/Example/pom.xml"); + Files.createDirectories(wrapper.getParent()); + Files.writeString(wrapper, "parentparent" + + "0exampleexample-lib" + + "1pom"); + return wrapper; + } + + @Test void downloadedFileAloneIsNotInstalled() throws Exception { + ProjectBinding binding = project(); + Path archive = Path.of(binding.projectDir()).getParent().resolve("cn1libs/Example.cn1lib"); + Files.createDirectories(archive.getParent()); + Files.writeString(archive, "downloaded archive"); + assertNull(new MavenLibraryInstaller().dependency(binding, "Example.cn1lib")); + } + + @Test void usesWrapperCoordinatesAndActualCommonParent() throws Exception { + ProjectBinding binding = project(); + wrapper(binding); + assertEquals("example:example-lib:1:pom", + new MavenLibraryInstaller().dependency(binding, "Example.cn1lib").coordinates()); + } + + @Test void reinstallsWithoutOverwritingWrapperOrRequiringArchive() throws Exception { + ProjectBinding binding = project(); + Path wrapper = wrapper(binding); + String original = Files.readString(wrapper); + MavenLibraryInstaller installer = new MavenLibraryInstaller() { + @Override void runMaven(List command, File common) { fail("Existing wrapper must be reused"); } + }; + String archive = wrapper.getParent().getParent().resolve("Example.cn1lib").toString(); + installer.install(binding, archive); + String updated = Files.readString(Path.of(binding.pom())); + assertTrue(PomEditor.containsDependency(updated, installer.dependency(binding, "Example.cn1lib"))); + installer.install(binding, archive); + assertEquals(updated, Files.readString(Path.of(binding.pom())), "reinstall is idempotent"); + assertEquals(original, Files.readString(wrapper)); + } + + @Test void successfulConversionAddsDependencyWithoutInjectionComment() throws Exception { + ProjectBinding binding = project(); + Path archive = root.resolve("Example.cn1lib"); + Files.writeString(archive, "archive"); + MavenLibraryInstaller installer = new MavenLibraryInstaller() { + @Override void runMaven(List command, File common) throws IOException { + assertEquals(Path.of(binding.projectDir()), common.toPath()); + try { wrapper(binding); } catch (Exception ex) { throw new IOException(ex); } + } + }; + installer.install(binding, archive.toString()); + assertTrue(Files.readString(Path.of(binding.pom())).contains("example-lib")); + } + + @Test void failedConversionDoesNotAddDependency() throws Exception { + ProjectBinding binding = project(); + Path archive = root.resolve("Example.cn1lib"); + Files.writeString(archive, "archive"); + String before = Files.readString(Path.of(binding.pom())); + MavenLibraryInstaller installer = new MavenLibraryInstaller() { + @Override void runMaven(List command, File common) throws IOException { + throw new IOException("Maven failed"); + } + }; + assertThrows(IOException.class, () -> installer.install(binding, archive.toString())); + assertEquals(before, Files.readString(Path.of(binding.pom()))); + } + + @Test void successfulExitWithoutWrapperIsStillAFailure() throws Exception { + ProjectBinding binding = project(); + Path archive = root.resolve("Example.cn1lib"); + Files.writeString(archive, "archive"); + MavenLibraryInstaller installer = new MavenLibraryInstaller() { + @Override void runMaven(List command, File common) { } + }; + assertThrows(IOException.class, () -> installer.install(binding, archive.toString())); + } + + @Test void mavenLaunchKeepsPathsAsSeparateArgumentsAndDoesNotUseAShell() throws Exception { + ProjectBinding binding = project(); + File archive = root.resolve("lib with spaces & punctuation.cn1lib").toFile(); + List command = new MavenLibraryInstaller().command(binding, archive); + assertTrue(command.get(0).endsWith("java") || command.get(0).endsWith("java.exe")); + assertTrue(command.contains("-Dfile=" + archive)); + assertTrue(command.contains("-Dmaven.home=" + new File(binding.mavenHome()).getCanonicalPath())); + assertTrue(command.contains("-Dmaven.multiModuleProjectDirectory=" + + new File(binding.projectDir()).getCanonicalFile().getParent())); + assertTrue(command.contains("--non-recursive")); + assertEquals("cn1:install-cn1lib", command.get(command.size() - 2)); + } + + @Test void reportsNonzeroMavenProcessExit() throws Exception { + ProjectBinding binding = project(); + String java = new MavenLibraryInstaller().command(binding, root.resolve("Example.cn1lib").toFile()).get(0); + IOException error = assertThrows(IOException.class, () -> new MavenLibraryInstaller().runMaven( + List.of(java, "-invalid-settings-test-option"), new File(binding.projectDir()))); + assertTrue(error.getMessage().contains("exit")); + assertTrue(error.getMessage().contains("settings.log")); + } + + @Test void rejectsPathsAndXmlExternalEntitiesInWrapper() throws Exception { + ProjectBinding binding = project(); + MavenLibraryInstaller installer = new MavenLibraryInstaller(); + assertThrows(IOException.class, () -> installer.dependency(binding, "../Example.cn1lib")); + Path wrapper = wrapper(binding); + Files.writeString(wrapper, "]>" + + "&x;"); + assertThrows(Exception.class, () -> installer.dependency(binding, "Example.cn1lib")); + } +} From 7a900c484b757206d229b145479e96c0a534b840 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:31:49 +0300 Subject: [PATCH 2/5] Address Settings installer review and copyright gate --- .../com/codename1/maven/OpenSettingsMojo.java | 52 +++++ .../codename1/maven/OpenSettingsMojoTest.java | 60 ++++++ .../settings/extensions/PomEditor.java | 204 +++++++++++------- .../settings/project/ProjectBinding.java | 15 ++ .../com/codename1/settings/PomEditorTest.java | 55 +++++ .../settings/MavenLibraryInstaller.java | 53 ++++- .../settings/MavenLibraryInstallerTest.java | 57 +++++ 7 files changed, 411 insertions(+), 85 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java index 0a9d9dcc754..927eb14d949 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java @@ -160,9 +160,24 @@ void writeBinding(File inputFile, File projectDir) throws MojoExecutionException // class than the build and reported the annotations on the // selected one as absent. + bindingValue("mavenHome", System.getProperty("maven.home")) + + mavenArgumentsBinding() + bindingValue("mainName", effective("codename1.mainName")) + bindingValue("packageName", effective("codename1.packageName")); try { + // User properties can contain credentials. Restrict access before + // writing, including when replacing an existing binding on POSIX. + java.nio.file.Path path = inputFile.toPath(); + java.nio.file.Files.createDirectories(path.toAbsolutePath().getParent()); + if (path.getFileSystem().supportedFileAttributeViews().contains("posix")) { + java.util.Set permissions = + java.nio.file.attribute.PosixFilePermissions.fromString("rw-------"); + if (!java.nio.file.Files.exists(path)) { + java.nio.file.Files.createFile(path, + java.nio.file.attribute.PosixFilePermissions.asFileAttribute(permissions)); + } else { + java.nio.file.Files.setPosixFilePermissions(path, permissions); + } + } FileUtils.write(inputFile, content, StandardCharsets.UTF_8); } catch (IOException ex) { throw new MojoExecutionException("Failed to write Settings binding", ex); @@ -222,6 +237,43 @@ private String effective(String key) { return properties == null ? null : properties.getProperty(key); } + /// Forward resolved session options rather than reparsing a shell command. + /// Each argument is encoded separately so whitespace, newlines and equals + /// signs in paths/property values survive the line-oriented binding. + private String mavenArgumentsBinding() { + MavenSession session = getSession(); + if (session == null || session.getRequest() == null) return ""; + org.apache.maven.execution.MavenExecutionRequest request = session.getRequest(); + java.util.List args = new java.util.ArrayList<>(); + if (request.getUserSettingsFile() != null && request.getUserSettingsFile().isFile()) { + args.add("--settings"); + args.add(request.getUserSettingsFile().getAbsolutePath()); + } + if (request.getGlobalSettingsFile() != null && request.getGlobalSettingsFile().isFile()) { + args.add("--global-settings"); + args.add(request.getGlobalSettingsFile().getAbsolutePath()); + } + if (request.isOffline()) args.add("--offline"); + if (request.isUpdateSnapshots()) args.add("--update-snapshots"); + for (String profile : request.getActiveProfiles()) args.add("-P" + profile); + for (String profile : request.getInactiveProfiles()) args.add("-P!" + profile); + java.util.Properties user = session.getUserProperties(); + if (user != null) { + for (String key : new java.util.TreeSet(user.stringPropertyNames())) { + args.add("-D" + key + "=" + user.getProperty(key)); + } + } + if (request.getLocalRepositoryPath() != null) { + args.add("-Dmaven.repo.local=" + request.getLocalRepositoryPath().getAbsolutePath()); + } + StringBuilder out = new StringBuilder(); + for (String arg : args) { + out.append(bindingValue("mavenArgument", java.util.Base64.getEncoder() + .encodeToString(arg.getBytes(StandardCharsets.UTF_8)))); + } + return out.toString(); + } + private static String bindingValue(String key, String value) { return value == null ? "" : key + "=" + value + "\n"; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java index ac13f3bff01..158a7ccfb09 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java @@ -44,6 +44,66 @@ public class OpenSettingsMojoTest { @Rule public TemporaryFolder tmp = new TemporaryFolder(); + @Test + public void forwardsMavenSessionOptionsInPrivateBinding() throws Exception { + org.apache.maven.execution.MavenExecutionRequest request = + new org.apache.maven.execution.DefaultMavenExecutionRequest(); + File userSettings = new File(tmp.getRoot(), "user settings.xml"); + File globalSettings = new File(tmp.getRoot(), "global settings.xml"); + Files.write(userSettings.toPath(), "".getBytes(StandardCharsets.UTF_8)); + Files.write(globalSettings.toPath(), "".getBytes(StandardCharsets.UTF_8)); + File repository = new File(tmp.getRoot(), "private repo"); + request.setUserSettingsFile(userSettings).setGlobalSettingsFile(globalSettings) + .setLocalRepositoryPath(repository).setOffline(true).setUpdateSnapshots(true) + .setActiveProfiles(Arrays.asList("private", "desktop")) + .setInactiveProfiles(Arrays.asList("public")); + java.util.Properties properties = new java.util.Properties(); + properties.setProperty("custom", "spaces & equals=and\nnewlines Ω "); + request.setUserProperties(properties); + final org.apache.maven.execution.MavenSession session = new org.apache.maven.execution.MavenSession( + null, null, request, new org.apache.maven.execution.DefaultMavenExecutionResult()); + OpenSettingsMojo mojo = new OpenSettingsMojo() { + @Override protected org.apache.maven.execution.MavenSession getSession() { return session; } + }; + for (String fileName : Arrays.asList("existing.input", "new.input")) { + File input = new File(tmp.getRoot(), fileName); + if (fileName.startsWith("existing")) Files.write(input.toPath(), new byte[0]); + mojo.writeBinding(input, tmp.newFolder(fileName + "-common")); + List arguments = new java.util.ArrayList<>(); + for (String line : Files.readAllLines(input.toPath(), StandardCharsets.UTF_8)) { + if (line.startsWith("mavenArgument=")) { + arguments.add(new String(java.util.Base64.getDecoder().decode( + line.substring("mavenArgument=".length())), StandardCharsets.UTF_8)); + } + } + assertEquals(Arrays.asList("--settings", userSettings.getAbsolutePath(), + "--global-settings", globalSettings.getAbsolutePath(), "--offline", "--update-snapshots", + "-Pprivate", "-Pdesktop", "-P!public", "-Dcustom=" + properties.getProperty("custom"), + "-Dmaven.repo.local=" + repository.getAbsolutePath()), arguments); + if (input.toPath().getFileSystem().supportedFileAttributeViews().contains("posix")) { + assertEquals(java.nio.file.attribute.PosixFilePermissions.fromString("rw-------"), + Files.getPosixFilePermissions(input.toPath())); + } + } + } + + @Test + public void absentDefaultSettingsFilesDoNotBecomeRequiredCliFiles() throws Exception { + org.apache.maven.execution.MavenExecutionRequest request = + new org.apache.maven.execution.DefaultMavenExecutionRequest(); + request.setUserSettingsFile(new File(tmp.getRoot(), "absent-user.xml")) + .setGlobalSettingsFile(new File(tmp.getRoot(), "absent-global.xml")); + final org.apache.maven.execution.MavenSession session = new org.apache.maven.execution.MavenSession( + null, null, request, new org.apache.maven.execution.DefaultMavenExecutionResult()); + OpenSettingsMojo mojo = new OpenSettingsMojo() { + @Override protected org.apache.maven.execution.MavenSession getSession() { return session; } + }; + File input = tmp.newFile("binding.input"); + mojo.writeBinding(input, tmp.newFolder("common")); + assertFalse(new String(Files.readAllBytes(input.toPath()), StandardCharsets.UTF_8) + .contains("mavenArgument=")); + } + @Test public void forwardsMavenHomeToSettingsBinding() throws Exception { String previous = System.getProperty("maven.home"); diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java index a077701483b..13a934cea52 100644 --- a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java +++ b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java @@ -54,22 +54,13 @@ public static boolean containsDependency(String pom, MavenDependency dependency) if (pom == null || dependency == null) { return false; } - int searchFrom = 0; - while (searchFrom < pom.length()) { - int start = pom.indexOf("', start); - int close = openEnd < 0 ? -1 : pom.indexOf("", openEnd); - if (openEnd < 0 || close < 0) { - return false; - } - String block = pom.substring(start, close + "".length()); - if (matches(block, dependency)) { - return true; + Element deps = child(parse(pom), "dependencies"); + if (deps != null) { + for (Element entry : deps.children) { + if ("dependency".equals(entry.name) && matches(pom, entry, dependency)) { + return true; + } } - searchFrom = close + "".length(); } return false; } @@ -87,90 +78,145 @@ public static String addDependency(String pom, MavenDependency dependency) { + " " + xml(dependency.version()) + "\n" + (dependency.type().length() == 0 ? "" : " " + xml(dependency.type()) + "\n") + " \n"; - int depsEnd = projectDependenciesEnd(pom); - if (depsEnd >= 0) { - return pom.substring(0, depsEnd) + xml + pom.substring(depsEnd); - } - int insertion = pom.indexOf(""); + Element project = parse(pom); + Element deps = child(project, "dependencies"); + if (deps != null) { + return insert(pom, deps, xml); } - if (insertion >= 0) { - return pom.substring(0, insertion) - + " \n" - + xml - + " \n" - + pom.substring(insertion); + if (project != null) { + return insert(pom, project, " \n" + xml + " \n"); } - return pom + "\n\n" + xml + "\n"; + throw new IllegalArgumentException("Cannot find the Maven project element"); } public static String removeDependency(String pom, MavenDependency dependency) { if (pom == null || dependency == null) { return pom; } - int searchFrom = 0; - while (searchFrom < pom.length()) { - int start = pom.indexOf("', start); - int close = openEnd < 0 ? -1 : pom.indexOf("", openEnd); - if (openEnd < 0 || close < 0) { - return pom; - } - int end = close + "".length(); - String block = pom.substring(start, end); - if (matches(block, dependency)) { - int lineStart = start; - while (lineStart > 0 && (pom.charAt(lineStart - 1) == ' ' || pom.charAt(lineStart - 1) == '\t')) { - lineStart--; + Element deps = child(parse(pom), "dependencies"); + if (deps == null) { + return pom; + } + for (Element entry : deps.children) { + if ("dependency".equals(entry.name) && matches(pom, entry, dependency)) { + int start = entry.start; + while (start > 0 && (pom.charAt(start - 1) == ' ' || pom.charAt(start - 1) == '\t')) { + start--; } - int lineEnd = end; - while (lineEnd < pom.length() && (pom.charAt(lineEnd) == ' ' || pom.charAt(lineEnd) == '\t')) { - lineEnd++; + int end = entry.end; + while (end < pom.length() && (pom.charAt(end) == ' ' || pom.charAt(end) == '\t')) { + end++; } - if (lineEnd < pom.length() && pom.charAt(lineEnd) == '\n') { - lineEnd++; + if (end < pom.length() && pom.charAt(end) == '\n') { + end++; } - return pom.substring(0, lineStart) + pom.substring(lineEnd); + return pom.substring(0, start) + pom.substring(end); } - searchFrom = end; } return pom; } - private static int projectDependenciesEnd(String pom) { - int profiles = pom.indexOf("", dependencyManagementStart); - int searchFrom = 0; - while (searchFrom < pom.length()) { - int start = pom.indexOf("= 0 && start > profiles) { - return -1; - } - int openEnd = pom.indexOf('>', start); - int close = openEnd < 0 ? -1 : pom.indexOf("", openEnd); - if (openEnd < 0 || close < 0) { - return -1; - } - boolean managed = dependencyManagementStart >= 0 - && start > dependencyManagementStart - && dependencyManagementEnd >= close; - if (!managed) { - return close; + private static String insert(String text, Element element, String xml) { + if (element.end == element.openEnd) { // Self-closing element. + return text.substring(0, element.openEnd - 2) + ">\n" + xml + + "" + text.substring(element.end); + } + return text.substring(0, element.close) + xml + text.substring(element.close); + } + + private static boolean matches(String pom, Element entry, MavenDependency dependency) { + return value(pom, child(entry, "groupId")).equals(xml(dependency.groupId())) + && value(pom, child(entry, "artifactId")).equals(xml(dependency.artifactId())); + } + + private static String value(String text, Element element) { + return element == null ? "" : text.substring(element.openEnd, element.close).trim(); + } + + private static Element child(Element parent, String name) { + if (parent != null) { + for (Element child : parent.children) { + if (name.equals(child.name)) { + return child; + } } - searchFrom = close + "".length(); } - return -1; + return null; } - private static boolean matches(String block, MavenDependency dependency) { - return block.contains("" + dependency.groupId() + "") - && block.contains("" + dependency.artifactId() + ""); + // Keep source offsets rather than serializing an XML tree: unrelated POM + // formatting and comments must survive edits. Only direct project children + // are eligible; plugin, managed and profile dependencies are separate scopes. + private static final class Element { + String name; + int start, openEnd, close, end; + java.util.List children = new java.util.ArrayList<>(); + } + + private static Element parse(String text) { + java.util.List stack = new java.util.ArrayList<>(); + Element root = null; + for (int pos = 0; (pos = text.indexOf('<', pos)) >= 0;) { + String terminator = text.startsWith("" + : text.startsWith("" + : text.startsWith("" : null; + if (terminator != null) { + int end = text.indexOf(terminator, pos + 2); + if (end < 0) throw new IllegalArgumentException("Incomplete XML markup"); + pos = end + terminator.length(); + continue; + } + if (text.startsWith("') { + break; + } + } + if (end == text.length()) throw new IllegalArgumentException("Incomplete XML tag"); + boolean closing = text.charAt(pos + 1) == '/'; + int nameStart = pos + (closing ? 2 : 1); + int nameEnd = nameStart; + while (nameEnd < end && !Character.isWhitespace(text.charAt(nameEnd)) + && text.charAt(nameEnd) != '/') nameEnd++; + String name = text.substring(nameStart, nameEnd); + if (closing) { + if (stack.isEmpty()) throw new IllegalArgumentException("Unexpected XML closing tag"); + Element element = stack.remove(stack.size() - 1); + if (!element.name.equals(name)) throw new IllegalArgumentException("Mismatched XML tag"); + element.close = pos; + element.end = end + 1; + } else { + Element element = new Element(); + element.name = name; + element.start = pos; + element.openEnd = end + 1; + if (stack.isEmpty()) { + if (root != null || !"project".equals(name)) { + throw new IllegalArgumentException("Expected one Maven project element"); + } + root = element; + } else { + stack.get(stack.size() - 1).children.add(element); + } + if (text.charAt(end - 1) == '/') { + element.close = element.end = end + 1; + } else { + stack.add(element); + } + } + pos = end + 1; + } + if (!stack.isEmpty()) throw new IllegalArgumentException("Unclosed XML element"); + return root; } private static String xml(String value) { diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java b/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java index 7154d044f80..4abc295f4a2 100644 --- a/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java +++ b/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectBinding.java @@ -30,6 +30,20 @@ public final class ProjectBinding { public static final String BUILD_GRADLE = "GRADLE"; private String mavenHome; + private final java.util.List mavenArguments = new java.util.ArrayList<>(); + + public java.util.List mavenArguments() { + return mavenArguments; + } + + private static String decodeArgument(String encoded) { + try { + return new String(com.codename1.util.Base64.decode(encoded.getBytes("UTF-8")), "UTF-8"); + } catch (java.io.UnsupportedEncodingException ex) { + throw new IllegalStateException("UTF-8 is unavailable", ex); + } + } + private String projectDir; private String settings; private String pom; @@ -174,6 +188,7 @@ public static ProjectBinding parse(String content) { String val = trimmed.substring(eq + 1).trim(); switch (key) { case "mavenHome" -> b.mavenHome = val; + case "mavenArgument" -> b.mavenArguments.add(decodeArgument(val)); case "projectDir" -> b.projectDir = val; case "settings" -> b.settings = val; case "pom" -> b.pom = val; diff --git a/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java b/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java index 181d88692c5..bd65b0d39ef 100644 --- a/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java +++ b/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.settings; import com.codename1.settings.extensions.MavenDependency; @@ -9,6 +31,39 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class PomEditorTest { + @Test + public void ignoresPluginManagedProfileAndCommentDependencies() throws Exception { + MavenDependency dep = new MavenDependency("example", "target", "1"); + String dependency = "exampletarget"; + String[] otherScopes = { + "", + "" + dependency + "", + "" + dependency + "", + "" + dependency + "", + "", + "]]>" + }; + for (String other : otherScopes) { + for (String projectDeps : new String[]{"", "", "", + ""}) { + String pom = " b\">" + other + projectDeps + ""; + assertFalse(PomEditor.containsDependency(pom, dep)); + assertEquals(pom, PomEditor.removeDependency(pom, dep)); + String installed = PomEditor.addDependency(pom, dep); + assertTrue(installed.contains(other), "Unrelated XML must remain byte-for-byte intact"); + assertTrue(PomEditor.containsDependency(installed, dep)); + org.w3c.dom.Document document = javax.xml.parsers.DocumentBuilderFactory.newInstance() + .newDocumentBuilder().parse(new org.xml.sax.InputSource(new java.io.StringReader(installed))); + assertEquals("1", javax.xml.xpath.XPathFactory.newInstance().newXPath().evaluate( + "count(/project/dependencies/dependency[artifactId='target'])", document)); + assertEquals(installed, PomEditor.addDependency(installed, dep)); + String removed = PomEditor.removeDependency(installed, dep); + assertTrue(removed.contains(other)); + assertFalse(PomEditor.containsDependency(removed, dep)); + } + } + } + @Test public void removingInstalledLegacyDependencyPreservesCompactPom() { String pom = ""; diff --git a/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java b/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java index 3832caf2542..1bfd97dca09 100644 --- a/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java +++ b/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java @@ -63,9 +63,7 @@ public void install(ProjectBinding binding, String libraryPath) throws Exception // dependency removed in Settings without overwriting its wrapper. File pom = new File(binding.pom()); String original = Files.readString(pom.toPath(), StandardCharsets.UTF_8); - String editable = PomEditor.containsDependency(original, dependency) ? original - : original.replaceAll("", ""); - String updated = PomEditor.addDependency(editable, dependency); + String updated = PomEditor.addDependency(original, dependency); if (!original.equals(updated)) { Files.writeString(pom.toPath(), updated, StandardCharsets.UTF_8); } @@ -98,9 +96,48 @@ public MavenDependency dependency(ProjectBinding binding, String fileName) throw if (!dependency.isValid()) { throw new IOException("Library wrapper has incomplete Maven coordinates: " + wrapper); } + File aggregator = new File(wrapper.getParentFile().getParentFile(), "pom.xml"); + String module = wrapper.getParentFile().getName(); + if (!aggregator.isFile() || !registered( + factory.newDocumentBuilder().parse(aggregator).getDocumentElement(), module)) { + throw new IOException("Incomplete library installation: " + wrapper + + " is not registered as module " + module + " in " + aggregator + + ". Restore the module registration before reinstalling. The wrapper was preserved."); + } return dependency; } + private static boolean registered(Element project, String module) { + if (hasModule(child(project, "modules"), module)) return true; + Element profiles = child(project, "profiles"); + if (profiles != null) { + for (Node node = profiles.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element && "profile".equals(node.getNodeName()) + && hasModule(child((Element) node, "modules"), module)) return true; + } + } + return false; + } + + private static boolean hasModule(Element modules, String name) { + if (modules != null) { + for (Node node = modules.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element && "module".equals(node.getNodeName()) + && name.equals(node.getTextContent().trim())) return true; + } + } + return false; + } + + private static Element child(Element parent, String name) { + if (parent != null) { + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element && name.equals(node.getNodeName())) return (Element) node; + } + } + return null; + } + private static String value(Element project, String name) { // Only direct children: the generated wrapper also contains its parent's // coordinates and dependencies with unrelated artifact IDs. @@ -122,13 +159,17 @@ List command(ProjectBinding binding, File library) throws IOException { boolean windows = System.getProperty("os.name", "").startsWith("Windows"); String java = new File(System.getProperty("java.home"), "bin/java" + (windows ? ".exe" : "")).getPath(); File root = new File(binding.projectDir()).getCanonicalFile().getParentFile(); - return new ArrayList<>(Arrays.asList(java, "-Duser.home=" + System.getProperty("user.home"), + List command = new ArrayList<>(Arrays.asList(java, "-Duser.home=" + System.getProperty("user.home"), "-Dmaven.home=" + home, "-Dmaven.multiModuleProjectDirectory=" + root, "-Dclassworlds.conf=" + new File(home, "bin/m2.conf"), "-cp", boot[0].getPath(), "org.codehaus.plexus.classworlds.launcher.Launcher", - "--batch-mode", "--non-recursive", "cn1:install-cn1lib", - "-Dfile=" + library.getAbsolutePath())); + "--batch-mode", "--non-recursive")); + command.addAll(binding.mavenArguments()); + // The selected archive always wins over a -Dfile from the Settings invocation. + command.add("cn1:install-cn1lib"); + command.add("-Dfile=" + library.getAbsolutePath()); + return command; } private File mavenHome(ProjectBinding binding) throws IOException { diff --git a/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java b/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java index f28fbe3097f..d6a7650dbb8 100644 --- a/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java +++ b/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java @@ -56,9 +56,66 @@ private Path wrapper(ProjectBinding binding) throws Exception { Files.writeString(wrapper, "parentparent" + "0exampleexample-lib" + "1pom"); + Files.writeString(wrapper.getParent().getParent().resolve("pom.xml"), + "Example.cn1lib-cn1lib" + + "${basedir}/Example/pom.xml" + + "Example"); return wrapper; } + @Test void retainsLaunchingMavenOptionsWithoutShellSplitting() throws Exception { + ProjectBinding original = project(); + List options = List.of("--settings", root.resolve("alternate settings.xml").toString(), + "--global-settings", root.resolve("global settings.xml").toString(), + "--offline", "-Pprivate", "-P!public", "-Dmaven.repo.local=" + root.resolve("local repo"), + "-Dcustom=spaces & equals=and\nnewlines Ω ", "-Dfile=old.cn1lib"); + StringBuilder content = new StringBuilder("projectDir=" + original.projectDir() + + "\nmavenHome=" + original.mavenHome() + "\n"); + for (String option : options) { + content.append("mavenArgument=").append(java.util.Base64.getEncoder().encodeToString( + option.getBytes(java.nio.charset.StandardCharsets.UTF_8))).append('\n'); + } + ProjectBinding binding = ProjectBinding.parse(content.toString()); + assertEquals(options, binding.mavenArguments()); + File archive = root.resolve("Example.cn1lib").toFile(); + List command = new MavenLibraryInstaller().command(binding, archive); + int first = command.indexOf("--non-recursive") + 1; + assertEquals(options, command.subList(first, first + options.size())); + assertEquals("-Dfile=" + archive, command.get(command.size() - 1)); + } + + @Test void partialConversionCannotBecomeSuccessOnRetry() throws Exception { + ProjectBinding binding = project(); + Path archive = root.resolve("Example.cn1lib"); + Files.writeString(archive, "archive"); + String commonBefore = Files.readString(Path.of(binding.pom())); + MavenLibraryInstaller installer = new MavenLibraryInstaller() { + @Override void runMaven(List command, File common) throws IOException { + try { + Path generated = wrapper(binding); + Files.delete(generated.getParent().getParent().resolve("pom.xml")); + } catch (Exception ex) { throw new IOException(ex); } + throw new IOException("Conversion interrupted after writing wrapper"); + } + }; + assertThrows(IOException.class, () -> installer.install(binding, archive.toString())); + Path generated = Path.of(binding.projectDir()).getParent().resolve("cn1libs/Example/pom.xml"); + String wrapperBefore = Files.readString(generated); + IOException retry = assertThrows(IOException.class, () -> installer.install(binding, archive.toString())); + assertTrue(retry.getMessage().contains("not registered as module Example")); + assertEquals(commonBefore, Files.readString(Path.of(binding.pom()))); + assertEquals(wrapperBefore, Files.readString(generated)); + Files.writeString(generated.getParent().getParent().resolve("pom.xml"), + "Other" + + ""); + assertThrows(IOException.class, () -> installer.dependency(binding, "Example.cn1lib")); + Files.writeString(generated.getParent().getParent().resolve("pom.xml"), + "Example"); + installer.install(binding, archive.toString()); + assertTrue(Files.readString(Path.of(binding.pom())).contains("example-lib")); + assertEquals(wrapperBefore, Files.readString(generated)); + } + @Test void downloadedFileAloneIsNotInstalled() throws Exception { ProjectBinding binding = project(); Path archive = Path.of(binding.projectDir()).getParent().resolve("cn1libs/Example.cn1lib"); From f4ec8ab9b9f1edb2d226bacee1fb994b9a4bce1d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:46:35 +0300 Subject: [PATCH 3/5] Handle Settings binding cleanup and failed library conversion --- .../com/codename1/maven/OpenSettingsMojo.java | 1 + .../codename1/maven/OpenSettingsMojoTest.java | 20 +++++++ .../settings/extensions/PomEditor.java | 32 ++++++++++ .../codename1/settings/project/ProjectIO.java | 17 +++++- .../com/codename1/settings/PomEditorTest.java | 20 +++++++ .../com/codename1/settings/ProjectIOTest.java | 53 ++++++++++++++++ .../settings/MavenLibraryInstaller.java | 60 +++++++++++++------ .../settings/MavenLibraryInstallerTest.java | 35 ++++++++++- 8 files changed, 216 insertions(+), 22 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java index 927eb14d949..e538f6efefd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/OpenSettingsMojo.java @@ -255,6 +255,7 @@ private String mavenArgumentsBinding() { } if (request.isOffline()) args.add("--offline"); if (request.isUpdateSnapshots()) args.add("--update-snapshots"); + if (request.isNoSnapshotUpdates()) args.add("--no-snapshot-updates"); for (String profile : request.getActiveProfiles()) args.add("-P" + profile); for (String profile : request.getInactiveProfiles()) args.add("-P!" + profile); java.util.Properties user = session.getUserProperties(); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java index 158a7ccfb09..09f3c266781 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/OpenSettingsMojoTest.java @@ -44,6 +44,26 @@ public class OpenSettingsMojoTest { @Rule public TemporaryFolder tmp = new TemporaryFolder(); + @Test + public void forwardsSnapshotUpdateSuppressionIndependentlyOfForcedUpdates() throws Exception { + for (boolean suppress : new boolean[]{false, true}) { + org.apache.maven.execution.MavenExecutionRequest request = + new org.apache.maven.execution.DefaultMavenExecutionRequest(); + request.setNoSnapshotUpdates(suppress); + final org.apache.maven.execution.MavenSession session = new org.apache.maven.execution.MavenSession( + null, null, request, new org.apache.maven.execution.DefaultMavenExecutionResult()); + OpenSettingsMojo mojo = new OpenSettingsMojo() { + @Override protected org.apache.maven.execution.MavenSession getSession() { return session; } + }; + File input = tmp.newFile("snapshot-" + suppress + ".input"); + mojo.writeBinding(input, tmp.newFolder("common-" + suppress)); + String content = new String(Files.readAllBytes(input.toPath()), StandardCharsets.UTF_8); + String flag = java.util.Base64.getEncoder().encodeToString( + "--no-snapshot-updates".getBytes(StandardCharsets.UTF_8)); + assertEquals(suppress, content.contains("mavenArgument=" + flag + "\n")); + } + } + @Test public void forwardsMavenSessionOptionsInPrivateBinding() throws Exception { org.apache.maven.execution.MavenExecutionRequest request = diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java index 13a934cea52..a331acde28e 100644 --- a/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java +++ b/scripts/settings/common/src/main/java/com/codename1/settings/extensions/PomEditor.java @@ -166,6 +166,11 @@ private static Element parse(String text) { pos = end + terminator.length(); continue; } + if (text.startsWith("" : "?>"; + int end = text.indexOf(terminator, pos + 2); + if (end < 0) throw new IllegalArgumentException("Incomplete POM DOCTYPE"); + pos = end + terminator.length() - 1; + } else if (ch == '\'' || ch == '"') { + quote = ch; + } else if (ch == '[') { + subset++; + } else if (ch == ']') { + subset--; + } else if (ch == '>' && subset == 0) { + return pos + 1; + } + } + throw new IllegalArgumentException("Incomplete POM DOCTYPE"); + } + private static String xml(String value) { return value.replace("&", "&") .replace("<", "<") diff --git a/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectIO.java b/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectIO.java index 426a81b5d89..196ac7f049a 100644 --- a/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectIO.java +++ b/scripts/settings/common/src/main/java/com/codename1/settings/project/ProjectIO.java @@ -40,19 +40,32 @@ public static ProjectBinding loadBinding() { return null; } InputStream in = null; + String url = fsUrl(path); + FileSystemStorage fs = FileSystemStorage.getInstance(); + boolean opened = false; try { - String url = fsUrl(path); - FileSystemStorage fs = FileSystemStorage.getInstance(); if (!fs.exists(url)) { return null; } in = fs.openInputStream(url); + opened = true; ProjectBinding b = ProjectBinding.parse(Util.readToString(in, "UTF-8")); return b.isValid() ? b : null; } catch (IOException ex) { return null; } finally { Util.cleanup(in); + // Launch bindings are single-use. Maven arguments may include + // credentials; retain them only in the parsed in-memory binding. + // Close first so deletion also works on Windows. Discard invalid + // or partially read bindings too, rather than leaving their data. + if (opened) { + fs.delete(url); + if (fs.exists(url)) { + System.err.println("Unable to delete Settings launch binding: " + path); + fs.deleteRetry(url, 3); + } + } } } diff --git a/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java b/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java index bd65b0d39ef..8ec5147eac4 100644 --- a/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java +++ b/scripts/settings/common/src/test/java/com/codename1/settings/PomEditorTest.java @@ -31,6 +31,26 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class PomEditorTest { + @Test + public void preservesDoctypeWithoutResolvingExternalResources() { + String[] declarations = {"", + "", + "", + " [ ]'>" + + " ?>]>", + }; + MavenDependency dep = new MavenDependency("example", "target", "1"); + for (String declaration : declarations) { + String pom = "\n" + declaration + + "\n"; + assertFalse(PomEditor.containsDependency(pom, dep)); + String installed = PomEditor.addDependency(pom, dep); + assertTrue(installed.contains(declaration)); + assertTrue(PomEditor.containsDependency(installed, dep)); + assertEquals(pom, PomEditor.removeDependency(installed, dep)); + } + } + @Test public void ignoresPluginManagedProfileAndCommentDependencies() throws Exception { MavenDependency dep = new MavenDependency("example", "target", "1"); diff --git a/scripts/settings/common/src/test/java/com/codename1/settings/ProjectIOTest.java b/scripts/settings/common/src/test/java/com/codename1/settings/ProjectIOTest.java index 66d12017af7..38fd2ad1039 100644 --- a/scripts/settings/common/src/test/java/com/codename1/settings/ProjectIOTest.java +++ b/scripts/settings/common/src/test/java/com/codename1/settings/ProjectIOTest.java @@ -31,6 +31,59 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class ProjectIOTest { + @org.junit.jupiter.api.io.TempDir java.nio.file.Path root; + + @Test + public void consumesBindingAfterClosingStreamEvenIfInvalidOrReadFails() throws Exception { + java.lang.reflect.Field implementation = com.codename1.io.Util.class.getDeclaredField("implInstance"); + implementation.setAccessible(true); + com.codename1.impl.CodenameOneImplementation previous = + (com.codename1.impl.CodenameOneImplementation) implementation.get(null); + com.codename1.impl.javase.JavaSEPort previousPort = com.codename1.impl.javase.JavaSEPort.instance; + String property = System.getProperty(ProjectIO.INPUT_PROPERTY); + try { + for (int scenario = 0; scenario < 3; scenario++) { + final boolean failRead = scenario == 2; + final boolean[] closed = {false}; + com.codename1.io.Util.setImplementation(new com.codename1.impl.javase.JavaSEPort() { + @Override public java.io.InputStream openFileInputStream(String file) throws java.io.IOException { + return new java.io.FilterInputStream(super.openFileInputStream(file)) { + @Override public int read(byte[] buffer, int offset, int length) throws java.io.IOException { + if (failRead) throw new java.io.IOException("Simulated binding read failure"); + return super.read(buffer, offset, length); + } + @Override public void close() throws java.io.IOException { + super.close(); + closed[0] = true; + } + }; + } + @Override public void deleteFile(String file) { + assertTrue(closed[0], "Close the stream before deleting on Windows"); + super.deleteFile(file); + } + }); + java.nio.file.Path input = root.resolve("settings-" + scenario + ".input"); + String argument = "-Dprivate.token=example secret"; + String content = (scenario == 1 ? "" : "settings=/app/codenameone_settings.properties\n") + + "mavenArgument=" + java.util.Base64.getEncoder().encodeToString( + argument.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + "\n"; + java.nio.file.Files.writeString(input, content); + System.setProperty(ProjectIO.INPUT_PROPERTY, input.toString()); + ProjectBinding loaded = ProjectIO.loadBinding(); + assertTrue(closed[0]); + org.junit.jupiter.api.Assertions.assertFalse(java.nio.file.Files.exists(input)); + if (scenario == 0) assertEquals(java.util.List.of(argument), loaded.mavenArguments()); + else assertNull(loaded); + } + } finally { + com.codename1.io.Util.setImplementation(previous); + com.codename1.impl.javase.JavaSEPort.instance = previousPort; + if (property == null) System.clearProperty(ProjectIO.INPUT_PROPERTY); + else System.setProperty(ProjectIO.INPUT_PROPERTY, property); + } + } + @Test public void unixPathsGetTheStandardFileUrlPrefix() { assertEquals("file:///Users/dev/app", ProjectIO.fsUrl("/Users/dev/app")); diff --git a/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java b/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java index 1bfd97dca09..0ca08ccb25c 100644 --- a/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java +++ b/scripts/settings/javase/src/desktop/java/com/codename1/settings/MavenLibraryInstaller.java @@ -46,26 +46,39 @@ public class MavenLibraryInstaller implements LegacyMavenInstaller { public void install(ProjectBinding binding, String libraryPath) throws Exception { File library = new File(libraryPath); File common = new File(binding.projectDir()); - MavenDependency dependency = dependency(binding, library.getName()); - if (dependency == null) { - if (!library.isFile()) { - throw new IOException("Library download is missing: " + library); - } - runMaven(command(binding, library), common); - dependency = dependency(binding, library.getName()); + File pom = new File(binding.pom()); + byte[] original = Files.readAllBytes(pom.toPath()); + try { + MavenDependency dependency = dependency(binding, library.getName()); if (dependency == null) { - throw new IOException("Maven did not create the library wrapper. See settings.log."); + if (!library.isFile()) { + throw new IOException("Library download is missing: " + library); + } + runMaven(command(binding, library), common); + dependency = dependency(binding, library.getName()); + if (dependency == null) { + throw new IOException("Maven did not create the library wrapper. See settings.log."); + } } - } - // The old converter requires an INJECT DEPENDENCIES comment. Projects - // without that marker still need a real dependency, not a success toast - // alongside a warning in the Maven output. Also supports reinstalling a - // dependency removed in Settings without overwriting its wrapper. - File pom = new File(binding.pom()); - String original = Files.readString(pom.toPath(), StandardCharsets.UTF_8); - String updated = PomEditor.addDependency(original, dependency); - if (!original.equals(updated)) { - Files.writeString(pom.toPath(), updated, StandardCharsets.UTF_8); + // The converter may already have injected a dependency. For POMs + // without its marker (and reinstalls), add the direct dependency. + String converted = Files.readString(pom.toPath(), StandardCharsets.UTF_8); + String updated = PomEditor.addDependency(converted, dependency); + if (!converted.equals(updated)) { + Files.writeString(pom.toPath(), updated, StandardCharsets.UTF_8); + } + } catch (Exception failure) { + // The converter edits common before registering the wrapper. A + // failure or missing registration must not leave an unresolved + // dependency behind. runMaven has reaped its child before returning. + try { + if (!Files.exists(pom.toPath()) || !Arrays.equals(original, Files.readAllBytes(pom.toPath()))) { + Files.write(pom.toPath(), original); + } + } catch (IOException restoreFailure) { + failure.addSuppressed(restoreFailure); + } + throw failure; } } @@ -198,8 +211,8 @@ void runMaven(List command, File common) throws IOException, Interrupted System.out.println("Running cn1:install-cn1lib in " + common); Process process = new ProcessBuilder(command).directory(common) .redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.INHERIT).start(); - process.getOutputStream().close(); try { + process.getOutputStream().close(); if (!process.waitFor(5, TimeUnit.MINUTES)) { throw new IOException("Maven library installation timed out. See settings.log."); } @@ -210,6 +223,15 @@ void runMaven(List command, File common) throws IOException, Interrupted } finally { if (process.isAlive()) { process.destroyForcibly(); + boolean interrupted = false; + while (process.isAlive()) { + try { + process.waitFor(); + } catch (InterruptedException ex) { + interrupted = true; + } + } + if (interrupted) Thread.currentThread().interrupt(); } } } diff --git a/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java b/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java index d6a7650dbb8..84876437859 100644 --- a/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java +++ b/scripts/settings/javase/src/test/java/com/codename1/settings/MavenLibraryInstallerTest.java @@ -63,11 +63,44 @@ private Path wrapper(ProjectBinding binding) throws Exception { return wrapper; } + @Test void restoresExactCommonPomAfterConverterEditAndFailure() throws Exception { + for (int scenario = 0; scenario < 3; scenario++) { + final int failure = scenario; + ProjectBinding binding = project(); + Path pom = Path.of(binding.pom()); + byte[] original = ("\r\n\r\n" + + "\r\n") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + Files.write(pom, original); + Path archive = root.resolve("Example.cn1lib"); + Files.writeString(archive, "archive"); + Path wrapper = Path.of(binding.projectDir()).getParent().resolve("cn1libs/Example/pom.xml"); + Files.deleteIfExists(wrapper); + MavenLibraryInstaller installer = new MavenLibraryInstaller() { + @Override void runMaven(List command, File common) throws IOException, InterruptedException { + try { + Path generated = wrapper(binding); + Files.writeString(pom, "example" + + "example-lib1" + + ""); + Files.delete(generated.getParent().getParent().resolve("pom.xml")); + } catch (Exception ex) { throw new IOException(ex); } + if (failure == 0) throw new IOException("Failed to register module"); + if (failure == 1) throw new InterruptedException("Timed out while registering module"); + // Even exit zero must roll back if wrapper validation fails. + } + }; + assertThrows(Exception.class, () -> installer.install(binding, archive.toString())); + assertArrayEquals(original, Files.readAllBytes(pom)); + assertTrue(Files.isRegularFile(wrapper), "Preserve wrapper for recovery"); + } + } + @Test void retainsLaunchingMavenOptionsWithoutShellSplitting() throws Exception { ProjectBinding original = project(); List options = List.of("--settings", root.resolve("alternate settings.xml").toString(), "--global-settings", root.resolve("global settings.xml").toString(), - "--offline", "-Pprivate", "-P!public", "-Dmaven.repo.local=" + root.resolve("local repo"), + "--offline", "--no-snapshot-updates", "-Pprivate", "-P!public", "-Dmaven.repo.local=" + root.resolve("local repo"), "-Dcustom=spaces & equals=and\nnewlines Ω ", "-Dfile=old.cn1lib"); StringBuilder content = new StringBuilder("projectDir=" + original.projectDir() + "\nmavenHome=" + original.mavenHome() + "\n"); From d2a72feceeefb8819bcdf8b6d26960f607e63b41 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:10:30 +0300 Subject: [PATCH 4/5] Allow safe library POM declarations and refresh screenshot bindings --- scripts/run-windows-tooling-tests.sh | 23 +++---- .../settings/MavenLibraryInstaller.java | 9 ++- .../settings/MavenLibraryInstallerTest.java | 61 ++++++++++++++++++- .../settings/scripts/capture-theme-gallery.sh | 16 ++--- 4 files changed, 87 insertions(+), 22 deletions(-) diff --git a/scripts/run-windows-tooling-tests.sh b/scripts/run-windows-tooling-tests.sh index 438fd668906..b81f340e6ec 100755 --- a/scripts/run-windows-tooling-tests.sh +++ b/scripts/run-windows-tooling-tests.sh @@ -136,16 +136,6 @@ mvn -B -q -f "$(winpath "$SETTINGS_CP_DIR/cp-pom.xml")" dependency:build-classpa "-Dmdep.outputFile=$(winpath "$SETTINGS_CP_DIR/cp.txt")" SETTINGS_CP="$(cat "$SETTINGS_CP_DIR/cp.txt")" -BINDING_FILE="$SETTINGS_CP_DIR/settings.input" -WORK_DIR_W="$(winpath "$WORK_DIR")" -cat > "$BINDING_FILE" < [extra jvm args...] @@ -153,10 +143,21 @@ run_settings_scenario() { local name="$1"; shift local png="$ARTIFACTS_DIR/settings-$name.png" local diag="$ARTIFACTS_DIR/settings-$name.txt" + # Settings consumes its launch binding after reading it. Every scenario + # needs its own file or only the first screenshot has a selected project. + local binding_file="$SETTINGS_CP_DIR/settings-$name.input" + local work_dir_w="$(winpath "$WORK_DIR")" + cat > "$binding_file" < command, File common) { fail("Reuse the existing wrapper"); } + }; + for (String declaration : List.of("", + "", + "", + "%remote;]>")) { + Files.writeString(wrapper, declaration + wrapperXml); + Files.writeString(aggregator, declaration + aggregatorXml); + installer.install(binding, root.resolve("Example.cn1lib").toString()); + assertEquals("example:example-lib:1:pom", installer.dependency(binding, "Example.cn1lib").coordinates()); + assertEquals(declaration + wrapperXml, Files.readString(wrapper)); + assertEquals(declaration + aggregatorXml, Files.readString(aggregator)); + } + } + + @Test void acceptsDoctypeAfterSuccessfulConversion() throws Exception { + ProjectBinding binding = project(); + Path archive = root.resolve("Example.cn1lib"); + Files.writeString(archive, "archive"); + MavenLibraryInstaller installer = new MavenLibraryInstaller() { + @Override void runMaven(List command, File common) throws IOException { + try { + Path generated = wrapper(binding); + Path aggregator = generated.getParent().getParent().resolve("pom.xml"); + Files.writeString(aggregator, "" + Files.readString(aggregator)); + } catch (Exception ex) { throw new IOException(ex); } + } + }; + installer.install(binding, archive.toString()); + assertTrue(PomEditor.containsDependency(Files.readString(Path.of(binding.pom())), + installer.dependency(binding, "Example.cn1lib"))); + } + + @Test void doesNotExpandExternalModuleEntityInAggregator() throws Exception { + ProjectBinding binding = project(); + Path generated = wrapper(binding); + Path entity = root.resolve("module.txt"); + Files.writeString(entity, "Example"); + Files.writeString(generated.getParent().getParent().resolve("pom.xml"), + "]>" + + "&module;"); + assertThrows(IOException.class, () -> new MavenLibraryInstaller().dependency(binding, "Example.cn1lib")); + } + @Test void rejectsPathsAndXmlExternalEntitiesInWrapper() throws Exception { ProjectBinding binding = project(); MavenLibraryInstaller installer = new MavenLibraryInstaller(); assertThrows(IOException.class, () -> installer.dependency(binding, "../Example.cn1lib")); Path wrapper = wrapper(binding); - Files.writeString(wrapper, "]>" - + "&x;"); - assertThrows(Exception.class, () -> installer.dependency(binding, "Example.cn1lib")); + Path entity = root.resolve("group.txt"); + Files.writeString(entity, "example"); + Files.writeString(wrapper, "]>" + + "&x;example-lib1"); + IOException failure = assertThrows(IOException.class, () -> installer.dependency(binding, "Example.cn1lib")); + assertTrue(failure.getMessage().contains("incomplete Maven coordinates")); } } diff --git a/scripts/settings/scripts/capture-theme-gallery.sh b/scripts/settings/scripts/capture-theme-gallery.sh index ec54b7e5759..62e672802c2 100755 --- a/scripts/settings/scripts/capture-theme-gallery.sh +++ b/scripts/settings/scripts/capture-theme-gallery.sh @@ -75,12 +75,6 @@ codename1.arg.desktop.themeMode=native EOF printf '4.0.0com.examplefieldnotes1.0\n' \ > "$PROJECT/pom.xml" -cat > "$WORK/binding.input" < $out" + # Each Settings process consumes its binding after loading the project. + binding="$WORK/$theme-$appearance.input" + cat > "$binding" < Date: Sat, 17 Oct 2026 10:22:18 +0300 Subject: [PATCH 5/5] Preserve shared Windows project path for Certificate Wizard --- scripts/run-windows-tooling-tests.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/run-windows-tooling-tests.sh b/scripts/run-windows-tooling-tests.sh index b81f340e6ec..de0d4423c21 100755 --- a/scripts/run-windows-tooling-tests.sh +++ b/scripts/run-windows-tooling-tests.sh @@ -135,6 +135,7 @@ EOF mvn -B -q -f "$(winpath "$SETTINGS_CP_DIR/cp-pom.xml")" dependency:build-classpath \ "-Dmdep.outputFile=$(winpath "$SETTINGS_CP_DIR/cp.txt")" SETTINGS_CP="$(cat "$SETTINGS_CP_DIR/cp.txt")" +WORK_DIR_W="$(winpath "$WORK_DIR")" MATRIX_FAILURES=() @@ -146,13 +147,12 @@ run_settings_scenario() { # Settings consumes its launch binding after reading it. Every scenario # needs its own file or only the first screenshot has a selected project. local binding_file="$SETTINGS_CP_DIR/settings-$name.input" - local work_dir_w="$(winpath "$WORK_DIR")" cat > "$binding_file" <