Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
88 changes: 87 additions & 1 deletion cli/src/main/java/com/devonfw/tools/ide/os/MacOsHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,6 +27,8 @@ public final class MacOsHelper {
private static final Set<String> 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;
Expand All @@ -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();
}

/**
Expand All @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}

Expand Down
11 changes: 1 addition & 10 deletions cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
}
Comment on lines -523 to -529
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be problematic on macos, but if you remove it, you also need to fix the code to determine the tool version that you broke by just removing this code-block.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

version detection still works just a different approach. before, linkDir pointed inside the .app bundle so the symlink landed there, and the version file had to be copied from rootDir into linkDir.
in my fix linkDir == rootDir, so the symlink points to the top level directory where the version file is already written no copy needed.
also copying into the .app bundle would break after codesigning since macOS seals it. binaries are still found correctly through findBinDir() in getToolBinPath() which navigates into the .app bundle to find executables.

ToolInstallation toolInstallation = new ToolInstallation(rootDir, linkDir, binDir, version, newInstallation);
setEnvironment(environmentContext, toolInstallation, additionalInstallation);
return toolInstallation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading