diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 2c3bfa2ff..4593f0498 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -8,6 +8,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1552[#1552]: Add Commandlet to fix TLS issue * https://github.com/devonfw/IDEasy/issues/1799[#1799]: Add support for file URL in GitUrl validation for local development +* https://github.com/devonfw/IDEasy/issues/451[#451]: Automatically remove macOS quarantine attribute after tool extraction * https://github.com/devonfw/IDEasy/issues/1760[#1760]: Accept empty input for single option The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/43?closed=1[milestone 2026.04.002]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/os/MacOsHelper.java b/cli/src/main/java/com/devonfw/tools/ide/os/MacOsHelper.java index 1d631427d..7bfec4615 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/os/MacOsHelper.java +++ b/cli/src/main/java/com/devonfw/tools/ide/os/MacOsHelper.java @@ -12,6 +12,8 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.tool.ToolCommandlet; import com.devonfw.tools.ide.tool.repository.ToolRepository; @@ -25,6 +27,8 @@ public final class MacOsHelper { private static final Set INVALID_LINK_FOLDERS = Set.of(IdeContext.FOLDER_CONTENTS, IdeContext.FOLDER_RESOURCES, IdeContext.FOLDER_BIN); + private final IdeContext context; + private final FileAccess fileAccess; private final SystemInfo systemInfo; @@ -36,7 +40,10 @@ public final class MacOsHelper { */ public MacOsHelper(IdeContext context) { - this(context.getFileAccess(), context.getSystemInfo()); + super(); + this.context = context; + this.fileAccess = context.getFileAccess(); + this.systemInfo = context.getSystemInfo(); } /** @@ -48,10 +55,54 @@ public MacOsHelper(IdeContext context) { public MacOsHelper(FileAccess fileAccess, SystemInfo systemInfo) { super(); + this.context = null; this.fileAccess = fileAccess; this.systemInfo = systemInfo; } + /** + * Fixes macOS Gatekeeper blocking for downloaded tools. On macOS 15.1+ (Apple Silicon), just removing {@code com.apple.quarantine} is not enough since + * unsigned apps still get the "is damaged" error. So we clear all xattrs first, then ad-hoc codesign any {@code .app} bundles. Call this after writing + * {@code .ide.software.version} since codesigning seals the bundle. + * + * @param path the {@link Path} to the installation directory. + */ + public void removeQuarantineAttribute(Path path) { + + if (!this.systemInfo.isMac()) { + return; + } + if (this.context == null) { + LOG.debug("Cannot fix Gatekeeper for {} - no context available", path); + return; + } + // clear all extended attributes (quarantine, resource forks, etc.) + LOG.debug("Clearing extended attributes from {}", path); + try { + this.context.newProcess().executable("xattr").addArgs("-cr", path).run(ProcessMode.DEFAULT_SILENT); + } catch (Exception e) { + LOG.warn("Could not clear extended attributes from {}: {}", path, e.getMessage(), e); + } + // ad-hoc codesign .app bundles only if they are not already properly signed (e.g. Eclipse is notarized - we must not replace that) + Path appDir = findAppDir(path); + if (appDir != null) { + try { + ProcessResult verifyResult = this.context.newProcess().executable("codesign") + .addArgs("-v", appDir).run(ProcessMode.DEFAULT_SILENT); + if (!verifyResult.isSuccessful()) { + LOG.debug("Ad-hoc codesigning {}", appDir); + ProcessResult signResult = this.context.newProcess().executable("codesign") + .addArgs("--force", "--deep", "--sign", "-", appDir).run(ProcessMode.DEFAULT_SILENT); + if (!signResult.isSuccessful()) { + LOG.warn("Could not codesign {} - app may be blocked by Gatekeeper", appDir); + } + } + } catch (Exception e) { + LOG.warn("Codesign not available for {}: {}", appDir, e.getMessage(), e); + } + } + } + /** * @param rootDir the {@link Path} to the root directory. * @return the path to the app directory. @@ -61,6 +112,41 @@ public Path findAppDir(Path rootDir) { p -> p.getFileName().toString().endsWith(".app") && Files.isDirectory(p), false); } + /** + * Finds the directory containing the tool's executables inside a macOS {@code .app} bundle, without requiring the binary name. This method exists separately + * from {@link #findLinkDir(Path, String)} because it is called from {@code getToolBinPath()}, which is itself called by {@code getBinaryName()} — using + * {@code findLinkDir} there would cause infinite recursion since it requires the binary name. + * + * @param rootDir the {@link Path} to the root directory that may contain a {@code .app} bundle. + * @return the binary directory inside the {@code .app} bundle, or {@code rootDir} if not a {@code .app} structure. + */ + public Path findBinDir(Path rootDir) { + + if (!this.systemInfo.isMac() || Files.isDirectory(rootDir.resolve(IdeContext.FOLDER_BIN))) { + return rootDir; + } + Path contentsDir = rootDir.resolve(IdeContext.FOLDER_CONTENTS); + if (!Files.isDirectory(contentsDir)) { + Path appDir = findAppDir(rootDir); + if (appDir == null) { + return rootDir; + } + contentsDir = appDir.resolve(IdeContext.FOLDER_CONTENTS); + if (!Files.isDirectory(contentsDir)) { + return rootDir; + } + } + Path resourcesApp = contentsDir.resolve(IdeContext.FOLDER_RESOURCES).resolve(IdeContext.FOLDER_APP); + if (Files.isDirectory(resourcesApp)) { + return resourcesApp; + } + Path macosDir = contentsDir.resolve("MacOS"); + if (Files.isDirectory(macosDir)) { + return macosDir; + } + return rootDir; + } + /** * @param rootDir the {@link Path} to the root directory. * @param tool the name of the tool to find the link directory for. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java index aa7ff9186..8f944dd30 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java @@ -65,6 +65,14 @@ public Path getToolBinPath() { if (Files.isDirectory(binPath)) { return binPath; } + // on macOS, toolPath may contain a .app bundle - resolve the actual binary dir (e.g. Contents/MacOS/) + if (Files.isDirectory(toolPath)) { + Path realPath = this.context.getFileAccess().toRealPath(toolPath); + Path macBinDir = this.context.getFileAccess().getBinPath(getMacOsHelper().findBinDir(realPath)); + if (!macBinDir.equals(realPath)) { + return macBinDir; + } + } return toolPath; } @@ -260,6 +268,8 @@ protected void performToolInstallation(ToolInstallRequest request, Path installa fileAccess.mkdirs(installationPath.getParent()); fileAccess.extract(downloadedToolFile, installationPath, this::postExtract, extract); this.context.writeVersionFile(resolvedVersion, installationPath); + // fix macOS Gatekeeper blocking - must run after version file is written but before any executables are launched + getMacOsHelper().removeQuarantineAttribute(installationPath); LOG.debug("Installed {} in version {} at {}", this.tool, resolvedVersion, installationPath); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java index 60a1668fe..575f9ed68 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java @@ -20,7 +20,6 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.environment.EnvironmentVariablesFiles; -import com.devonfw.tools.ide.io.FileCopyMode; import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.nls.NlsBundle; import com.devonfw.tools.ide.os.MacOsHelper; @@ -500,8 +499,7 @@ protected ToolInstallation createToolInstallation(Path rootDir, VersionIdentifie Path binDir = rootDir; if (rootDir != null) { // on MacOS applications have a very strange structure - see JavaDoc of findLinkDir and ToolInstallation.linkDir for details. - linkDir = getMacOsHelper().findLinkDir(rootDir, getBinaryName()); - binDir = this.context.getFileAccess().getBinPath(linkDir); + binDir = this.context.getFileAccess().getBinPath(getMacOsHelper().findLinkDir(rootDir, getBinaryName())); } return createToolInstallation(rootDir, linkDir, binDir, version, newInstallation, environmentContext, additionalInstallation); } @@ -520,13 +518,6 @@ protected ToolInstallation createToolInstallation(Path rootDir, VersionIdentifie protected ToolInstallation createToolInstallation(Path rootDir, Path linkDir, Path binDir, VersionIdentifier version, boolean newInstallation, EnvironmentContext environmentContext, boolean additionalInstallation) { - if (linkDir != rootDir) { - assert (!linkDir.equals(rootDir)); - Path toolVersionFile = rootDir.resolve(IdeContext.FILE_SOFTWARE_VERSION); - if (Files.exists(toolVersionFile)) { - this.context.getFileAccess().copy(toolVersionFile, linkDir, FileCopyMode.COPY_FILE_OVERRIDE); - } - } ToolInstallation toolInstallation = new ToolInstallation(rootDir, linkDir, binDir, version, newInstallation); setEnvironment(environmentContext, toolInstallation, additionalInstallation); return toolInstallation; diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/eclipse/EclipseTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/eclipse/EclipseTest.java index 3e8dae7ff..bca2ebdfe 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/eclipse/EclipseTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/eclipse/EclipseTest.java @@ -49,7 +49,7 @@ void testEclipse(String os) throws IOException { new IdeLogEntry(IdeLogLevel.SUCCESS, "Successfully installed eclipse in version 2024-09", true)); assertThat(context).logAtSuccess().hasMessage("Successfully ended step 'Install plugin anyedit'."); assertThat(context.getPluginsPath().resolve("eclipse")).isDirectory(); - assertThat(eclipsePath.resolve("eclipsetest")).hasContent( + assertThat(eclipse.getToolBinPath().resolve("eclipsetest")).hasContent( "eclipse " + os + " -data " + context.getWorkspacePath() + " -keyring " + context.getUserHome().resolve(".eclipse").resolve(".keyring") + " -configuration " + context.getPluginsPath().resolve("eclipse").resolve("configuration") + " gui -showlocation eclipseproject"); diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/jmc/JmcTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/jmc/JmcTest.java index c54307edf..1fa53fad6 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/jmc/JmcTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/jmc/JmcTest.java @@ -76,7 +76,8 @@ private void checkInstallation(IdeTestContext context) { assertThat(context.getSoftwarePath().resolve("jmc/HelloWorld.txt")).hasContent("Hello World!"); assertThat(context.getSoftwarePath().resolve("jmc/JDK Mission Control")).doesNotExist(); } else if (context.getSystemInfo().isMac()) { - assertThat(context.getSoftwarePath().resolve("jmc/jmc")).exists(); + Jmc jmc = context.getCommandletManager().getCommandlet(Jmc.class); + assertThat(jmc.getToolBinPath().resolve("jmc")).exists(); } assertThat(context.getSoftwarePath().resolve("jmc/.ide.software.version")).exists().hasContent("8.3.0"); assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed jmc in version 8.3.0");