diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 19f07fdd8e..02a0213fd6 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -11,6 +11,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1978[#1978]: Enhanced the quality status documentation * https://github.com/devonfw/IDEasy/issues/1946[#1946]: Add Spyder Python IDE * https://github.com/devonfw/IDEasy/issues/1846[#1846]: Fixed macOS Gatekeeper workaround +* https://github.com/devonfw/IDEasy/issues/798[#798]: Fixed pgAdmin DMG install on macOS * https://github.com/devonfw/IDEasy/issues/1906[#1906]: Fixed `ide uninstall` failing on macOS * https://github.com/devonfw/IDEasy/issues/1985[#1985]: Fix path traversal errors when extracting ZIPs on Windows * https://github.com/devonfw/IDEasy/issues/1255[#1255]: Enhance snapshot version recognition in IDEasy diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java index 83030135d0..b9cbc2080b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java @@ -112,6 +112,9 @@ public interface IdeContext extends IdeStartContext { /** The name of the Contents folder inside a MacOS app. */ String FOLDER_CONTENTS = "Contents"; + /** The name of the MacOS folder inside the Contents of a MacOS app, holding the executable. */ + String FOLDER_MAC_OS = "MacOS"; + /** The name of the Resources folder inside a MacOS app. */ String FOLDER_RESOURCES = "Resources"; diff --git a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java index d57d7a1fb2..18f763d520 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java @@ -62,6 +62,7 @@ import com.devonfw.tools.ide.io.ini.IniSection; import com.devonfw.tools.ide.os.SystemInfoImpl; import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.util.DateTimeUtil; @@ -993,18 +994,29 @@ public void extractDmg(Path file, Path targetDir) { Path mountPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME); mkdirs(mountPath); - ProcessContext pc = this.context.newProcess(); - pc.executable("hdiutil"); - pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file); - pc.run(); - Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false); - if (appPath == null) { - throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file); - } + boolean mounted = false; + try { + ProcessContext pc = this.context.newProcess(); + pc.executable("hdiutil"); + pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file); + pc.run(); + mounted = true; + Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false); + if (appPath == null) { + throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file); + } - copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE); - pc.addArgs("detach", "-force", mountPath); - pc.run(); + // use ditto to copy the .app: a normal recursive copy breaks on the symlinks inside macOS frameworks (e.g. Python/Tcl) + Path targetApp = targetDir.resolve(appPath.getFileName().toString()); + mkdirs(targetDir); + delete(targetApp); + this.context.newProcess().executable("ditto").addArgs(appPath, targetApp).run(); + } finally { + if (mounted) { + this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable("hdiutil").addArgs("detach", "-force", mountPath) + .run(ProcessMode.DEFAULT_SILENT); + } + } } @Override diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java index acff7cc211..6d37d877cf 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java @@ -19,6 +19,7 @@ import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.repository.ToolRepository; +import com.devonfw.tools.ide.util.FilenameUtil; import com.devonfw.tools.ide.version.VersionIdentifier; /** @@ -77,16 +78,26 @@ protected boolean runWithPackageManager(boolean silent, List commands) { IdeLogLevel level = IdeLogLevel.INTERACTION; level.log(LOG, "We need to run the following privileged command(s):"); - for (String command : pmCommand.commands()) { + for (String command : commands) { level.log(LOG, command); } level.log(LOG, "This will require root permissions!"); } + private void logPackageManagerCommands(PackageManagerCommand pmCommand) { + + logPrivilegedCommands(pmCommand.commands()); + } + /** * Executes the provided package manager command. * @@ -147,30 +158,35 @@ protected ToolInstallation doInstall(ToolInstallRequest request) { ToolRepository toolRepository = this.context.getDefaultToolRepository(); resolvedVersion = cveCheck(request); // download and install the global tool - FileAccess fileAccess = this.context.getFileAccess(); Path target = toolRepository.download(this.tool, edition, resolvedVersion, this); - Path executable = target; - Path tmpDir = null; - boolean extract = isExtract(); - if (extract) { - tmpDir = fileAccess.createTempDir(getName()); - Path downloadBinaryPath = tmpDir.resolve(target.getFileName()); - fileAccess.extract(target, downloadBinaryPath); - executable = fileAccess.findFirst(downloadBinaryPath, Files::isExecutable, false); - } - ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable(executable); - int exitCode = pc.run(ProcessMode.BACKGROUND_SILENT).getExitCode(); - if (tmpDir != null) { - fileAccess.delete(tmpDir); - } - if (exitCode == 0) { - IdeLogLevel.SUCCESS.log(LOG, "Installation process for {} in version {} has started", this.tool, resolvedVersion); - Step step = request.getStep(); - if (step != null) { - step.success(true); - } + ProcessContext pc; + if (isMacDmg(target)) { + installMacDmg(target); + pc = request.getProcessContext(); } else { - throw new CliException("Installation process for " + this.tool + " in version " + resolvedVersion + " failed with exit code " + exitCode + "!"); + FileAccess fileAccess = this.context.getFileAccess(); + Path executable = target; + Path tmpDir = null; + boolean extract = isExtract(); + if (extract) { + tmpDir = fileAccess.createTempDir(getName()); + Path downloadBinaryPath = tmpDir.resolve(target.getFileName()); + fileAccess.extract(target, downloadBinaryPath); + executable = fileAccess.findFirst(downloadBinaryPath, Files::isExecutable, false); + } + pc = this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable(executable); + int exitCode = pc.run(ProcessMode.BACKGROUND_SILENT).getExitCode(); + if (tmpDir != null) { + fileAccess.delete(tmpDir); + } + if (exitCode != 0) { + throw new CliException("Installation process for " + this.tool + " in version " + resolvedVersion + " failed with exit code " + exitCode + "!"); + } + } + IdeLogLevel.SUCCESS.log(LOG, "Installation process for {} in version {} has started", this.tool, resolvedVersion); + Step step = request.getStep(); + if (step != null) { + step.success(true); } installationPath = getInstallationPath(toolEdition.edition(), resolvedVersion); if (installationPath == null) { @@ -180,6 +196,69 @@ protected ToolInstallation doInstall(ToolInstallRequest request) { return createToolInstallation(installationPath, resolvedVersion, true, pc, false); } + private void installMacDmg(Path downloadedToolFile) { + + FileAccess fileAccess = this.context.getFileAccess(); + Path tmpDir = fileAccess.createTempDir(getName()); + try { + fileAccess.extractDmg(downloadedToolFile, tmpDir); + Path sourceApp = getMacOsHelper().findAppDir(tmpDir); + if (sourceApp == null) { + throw new CliException("Failed to install " + this.tool + " from " + downloadedToolFile + " because no MacOS *.app was found."); + } + Path targetApp = getMacApplicationsPath().resolve(sourceApp.getFileName().toString()); + copyMacApplicationToApplications(sourceApp, targetApp); + } finally { + fileAccess.delete(tmpDir); + } + } + + /** + * Copies a macOS application bundle to the global applications folder. + * + * @param sourceApp the extracted source {@code .app}. + * @param targetApp the target {@code .app} in {@link #getMacApplicationsPath()}. + */ + protected void copyMacApplicationToApplications(Path sourceApp, Path targetApp) { + + runPrivilegedCommands(List.of( + List.of("/bin/rm", "-rf", targetApp.toString()), + List.of("/usr/bin/ditto", sourceApp.toString(), targetApp.toString()))); + } + + private void runPrivilegedCommands(List> commands) { + + logPrivilegedCommands(commands.stream().map(this::toSudoCommandLine).toList()); + for (List command : commands) { + int exitCode = this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable("sudo").addArgs(command).run(); + if (exitCode != 0) { + throw new CliException("Privileged command failed with exit code " + exitCode + ": " + toSudoCommandLine(command)); + } + } + } + + private String toSudoCommandLine(List command) { + + return "sudo " + String.join(" ", command); + } + + private boolean isMacDmg(Path file) { + + if (!this.context.getSystemInfo().isMac()) { + return false; + } + String extension = FilenameUtil.getExtension(file.toString()); + return "dmg".equals(extension); + } + + /** + * @return the macOS applications folder where global {@code .dmg} tools are installed. + */ + protected Path getMacApplicationsPath() { + + return Path.of("/Applications"); + } + /** * @return the {@link List} of {@link PackageManagerCommand}s to use on Linux to install this tool. If empty, no package manager installation will be * triggered on Linux. @@ -206,6 +285,9 @@ protected Path getInstallationPath(String edition, VersionIdentifier resolvedVer Path toolBinary = Path.of(getBinaryName()); Path binaryPath = this.context.getPath().findBinary(toolBinary); if ((binaryPath == toolBinary) || !Files.exists(binaryPath)) { + if (this.context.getSystemInfo().isMac()) { + return getMacApplicationInstallationPath(); + } return null; } Path binPath = binaryPath.getParent(); @@ -215,6 +297,30 @@ protected Path getInstallationPath(String edition, VersionIdentifier resolvedVer return this.context.getFileAccess().getBinParentPath(binPath); } + private Path getMacApplicationInstallationPath() { + + Path appPath = this.context.getFileAccess().findFirst(getMacApplicationsPath(), this::isMacApplicationForTool, false); + if (appPath == null) { + return null; + } + Path binaryPath = getMacApplicationBinaryPath(appPath); + this.context.getPath().setPath(getName(), binaryPath.getParent()); + return appPath; + } + + private boolean isMacApplicationForTool(Path appPath) { + + if (!Files.isDirectory(appPath) || !appPath.getFileName().toString().endsWith(".app")) { + return false; + } + return Files.isExecutable(getMacApplicationBinaryPath(appPath)); + } + + private Path getMacApplicationBinaryPath(Path appPath) { + + return appPath.resolve(IdeContext.FOLDER_CONTENTS).resolve(IdeContext.FOLDER_MAC_OS).resolve(getBinaryName()); + } + @Override public void uninstall() { //TODO: handle "uninstall " diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java b/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java index 9652701931..ed0e866da1 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java @@ -22,6 +22,8 @@ */ public class PgAdmin extends GlobalToolCommandlet { + private static final String PGADMIN_MAC_BINARY = "pgAdmin 4"; + /** * The constructor. * @@ -74,15 +76,21 @@ private List getPackageManagerCommandsUninstall() { @Override protected String getBinaryName() { + if (this.context.getSystemInfo().isMac()) { + return PGADMIN_MAC_BINARY; + } return "pgadmin4"; } @Override protected Path getInstallationPath(String edition, VersionIdentifier resolvedVersion) { - if (super.getInstallationPath(edition, resolvedVersion) == null) { - if (this.context.getSystemInfo().isWindows()) { - return getExecutableFolderFromWindowsRegistry(); - } + + Path installationPath = super.getInstallationPath(edition, resolvedVersion); + if (installationPath != null) { + return installationPath; + } + if (this.context.getSystemInfo().isWindows()) { + return getExecutableFolderFromWindowsRegistry(); } return null; } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java new file mode 100644 index 0000000000..d2f1ff293b --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java @@ -0,0 +1,105 @@ +package com.devonfw.tools.ide.tool; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * Test of {@link GlobalToolCommandlet}. + */ +class GlobalToolCommandletTest extends AbstractIdeContextTest { + + private static final String DUMMY_BINARY = "dummy"; + + /** + * Tests that on macOS the installation path is resolved from the app bundle in the applications folder and its executable folder is registered. + * + * @param tempDir the temporary directory. + * @throws IOException on test setup failure. + */ + @Test + @DisabledOnOs(OS.WINDOWS) + void testGetInstallationPathFindsMacApp(@TempDir Path tempDir) throws IOException { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.setSystemInfo(SystemInfoMock.MAC_X64); + Path applicationsPath = tempDir.resolve("Applications"); + Path appPath = applicationsPath.resolve("Dummy.app"); + Path appBinPath = appPath.resolve("Contents").resolve("MacOS"); + Path binary = appBinPath.resolve(DUMMY_BINARY); + Files.createDirectories(appBinPath); + Files.writeString(binary, "test"); + context.getFileAccess().makeExecutable(binary); + GlobalToolCommandlet globalTool = new GlobalToolDummyCommandlet(context, applicationsPath); + + // act + Path result = globalTool.getInstallationPath("default", VersionIdentifier.of("1.0")); + + // assert + assertThat(result).isEqualTo(appPath); + assertThat(context.getPath().getPath(DUMMY_BINARY)).isEqualTo(appBinPath); + } + + /** + * Tests that the applications folder is only searched on macOS and not on other operating systems. + * + * @param tempDir the temporary directory. + * @throws IOException on test setup failure. + */ + @Test + @DisabledOnOs(OS.WINDOWS) + void testGetInstallationPathDoesNotSearchApplicationsOnLinux(@TempDir Path tempDir) throws IOException { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + Path applicationsPath = tempDir.resolve("Applications"); + Path appBinPath = applicationsPath.resolve("Dummy.app").resolve("Contents").resolve("MacOS"); + Path binary = appBinPath.resolve(DUMMY_BINARY); + Files.createDirectories(appBinPath); + Files.writeString(binary, "test"); + context.getFileAccess().makeExecutable(binary); + GlobalToolCommandlet globalTool = new GlobalToolDummyCommandlet(context, applicationsPath); + + // act & assert + assertThat(globalTool.getInstallationPath("default", VersionIdentifier.of("1.0"))).isNull(); + assertThat(context.getPath().getPath(DUMMY_BINARY)).isNull(); + } + + private static class GlobalToolDummyCommandlet extends GlobalToolCommandlet { + + private final Path applicationsPath; + + GlobalToolDummyCommandlet(IdeContext context, Path applicationsPath) { + + super(context, DUMMY_BINARY, Set.of(Tag.TEST)); + this.applicationsPath = applicationsPath; + } + + @Override + protected String getBinaryName() { + + return DUMMY_BINARY; + } + + @Override + protected Path getMacApplicationsPath() { + + return this.applicationsPath; + } + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java new file mode 100644 index 0000000000..4391ffda24 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java @@ -0,0 +1,118 @@ +package com.devonfw.tools.ide.tool.pgadmin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * Test of {@link PgAdmin}. + */ +class PgAdminTest extends AbstractIdeContextTest { + + /** + * Tests that the pgAdmin binary name matches the target operating system. + * + * @param os the operating system to simulate. + * @param binaryName the expected binary name. + */ + @ParameterizedTest + @CsvSource({ "windows, pgadmin4", "linux, pgadmin4", "mac, pgAdmin 4" }) + void testGetBinaryName(String os, String binaryName) { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.setSystemInfo(SystemInfoMock.of(os)); + PgAdmin pgAdmin = new PgAdmin(context); + + // act & assert + assertThat(pgAdmin.getBinaryName()).isEqualTo(binaryName); + } + + /** + * Tests that {@link PgAdmin#getInstallationPath(String, VersionIdentifier)} preserves the generic PATH based result. + * + * @param tempDir the temporary directory. + * @throws IOException on test setup failure. + */ + @Test + @DisabledOnOs(OS.WINDOWS) + void testGetInstallationPathKeepsPathResult(@TempDir Path tempDir) throws IOException { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + Path installationPath = tempDir.resolve("pgadmin"); + Path binPath = installationPath.resolve("bin"); + Path binary = binPath.resolve("pgadmin4"); + Files.createDirectories(binPath); + Files.writeString(binary, "test"); + context.getFileAccess().makeExecutable(binary); + context.getPath().setPath("pgadmin", binPath); + PgAdmin pgAdmin = new TestPgAdmin(context, tempDir.resolve("Applications")); + + // act + Path result = pgAdmin.getInstallationPath("pgadmin", VersionIdentifier.of("8.13")); + + // assert + assertThat(result).isEqualTo(installationPath); + } + + /** + * Tests that macOS installation detection resolves the pgAdmin app bundle and registers its executable folder. + * + * @param tempDir the temporary directory. + * @throws IOException on test setup failure. + */ + @Test + @DisabledOnOs(OS.WINDOWS) + void testGetInstallationPathFindsMacApp(@TempDir Path tempDir) throws IOException { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.setSystemInfo(SystemInfoMock.MAC_X64); + Path applicationsPath = tempDir.resolve("Applications"); + Path appPath = applicationsPath.resolve("pgAdmin 4.app"); + Path appBinPath = appPath.resolve("Contents").resolve("MacOS"); + Path binary = appBinPath.resolve("pgAdmin 4"); + Files.createDirectories(appBinPath); + Files.writeString(binary, "test"); + context.getFileAccess().makeExecutable(binary); + PgAdmin pgAdmin = new TestPgAdmin(context, applicationsPath); + + // act + Path result = pgAdmin.getInstallationPath("pgadmin", VersionIdentifier.of("8.13")); + + // assert + assertThat(result).isEqualTo(appPath); + assertThat(context.getPath().getPath("pgadmin")).isEqualTo(appBinPath); + } + + private static class TestPgAdmin extends PgAdmin { + + private final Path applicationsPath; + + TestPgAdmin(IdeTestContext context, Path applicationsPath) { + + super(context); + this.applicationsPath = applicationsPath; + } + + @Override + protected Path getMacApplicationsPath() { + + return this.applicationsPath; + } + } +}