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
56 changes: 55 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,9 @@

import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.io.FileAccess;
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.tool.ToolCommandlet;
import com.devonfw.tools.ide.tool.repository.ToolRepository;

Expand All @@ -25,6 +28,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 +41,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 +56,56 @@ 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);
Comment thread
shodiBoy1 marked this conversation as resolved.
} 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")
.errorHandling(ProcessErrorHandling.NONE)
.addArgs("-v", appDir).run(ProcessMode.DEFAULT_SILENT);
if (!verifyResult.isSuccessful()) {
LOG.debug("Ad-hoc codesigning {}", appDir);
ProcessResult signResult = this.context.newProcess().executable("codesign")
.errorHandling(ProcessErrorHandling.LOG_WARNING)
.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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,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 Expand Up @@ -350,9 +352,13 @@ protected VersionIdentifier getInstalledVersion(Path toolPath) {
if (isToolNotInstalled(toolPath)) {
return null;
}
Path toolVersionFile = toolPath.resolve(IdeContext.FILE_SOFTWARE_VERSION);
Path versionLookupPath = getInstalledSoftwareRepoPath(toolPath, false);
if (versionLookupPath == null) {
versionLookupPath = toolPath;
}
Path toolVersionFile = versionLookupPath.resolve(IdeContext.FILE_SOFTWARE_VERSION);
if (!Files.exists(toolVersionFile)) {
Path legacyToolVersionFile = toolPath.resolve(IdeContext.FILE_LEGACY_SOFTWARE_VERSION);
Path legacyToolVersionFile = versionLookupPath.resolve(IdeContext.FILE_LEGACY_SOFTWARE_VERSION);
if (Files.exists(legacyToolVersionFile)) {
toolVersionFile = legacyToolVersionFile;
} else {
Expand Down Expand Up @@ -416,13 +422,17 @@ private String getEdition(Path toolRepoFolder, Path toolInstallFolder) {
}

private Path getInstalledSoftwareRepoPath(Path toolPath) {
return getInstalledSoftwareRepoPath(toolPath, false);
}

private Path getInstalledSoftwareRepoPath(Path toolPath, boolean logIfNotSoftwareRepo) {
if (isToolNotInstalled(toolPath)) {
return null;
}
Path installPath = this.context.getFileAccess().toRealPath(toolPath);
// if the installPath changed, a link has been resolved
if (installPath.equals(toolPath)) {
if (!isIgnoreSoftwareRepo()) {
if (logIfNotSoftwareRepo && !isIgnoreSoftwareRepo()) {
LOG.warn("Tool {} is not installed via software repository (maybe from devonfw-ide). Please consider reinstalling it.", this.tool);
}
// I do not see any reliable way how we could determine the edition of a tool that does not use software repo or that was installed by devonfw-ide
Expand Down Expand Up @@ -510,7 +520,7 @@ protected void performUninstall(Path toolPath) {
* Deletes the installed version of the tool from the shared software repository.
*/
private void uninstallFromSoftwareRepository(Path toolPath) {
Path repoPath = getInstalledSoftwareRepoPath(toolPath);
Path repoPath = getInstalledSoftwareRepoPath(toolPath, true);
if ((repoPath == null) || !Files.exists(repoPath)) {
LOG.warn("An installed version of {} does not exist in software repository.", this.tool);
return;
Expand Down
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 @@ -520,13 +519,7 @@ 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.

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.

@hohwille you were right, I didn't fully understand how linkDir and rootDir work together. i broke the linkDir assignment by collapsing two lines into one. I restored it so linkDir points inside the
.app bundle again. instead of copying the version file into the bundle (which would break codesigning), I changed getInstalledVersion to follow the symlink back to rootDir where the version file already lives.

// do not copy the version file into macOS .app bundles: changing the bundle after codesigning breaks the seal.
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 @@ -86,7 +86,8 @@ void testAndroidStudioRun(String os, WireMockRuntimeInfo wmRuntimeInfo) throws I

private void checkInstallation(IdeTestContext context) {
// commandlet - android-studio
assertThat(context.getSoftwarePath().resolve("android-studio/.ide.software.version")).exists().hasContent("2024.1.1.1");
AndroidStudio commandlet = context.getCommandletManager().getCommandlet(AndroidStudio.class);
assertThat(commandlet.getInstalledVersion().toString()).isEqualTo("2024.1.1.1");
assertThat(context).log().hasEntries(new IdeLogEntry(IdeLogLevel.SUCCESS, "Successfully ended step 'Install plugin MockedPlugin'.", true), //
new IdeLogEntry(IdeLogLevel.SUCCESS, "Successfully installed android-studio in version 2024.1.1.1", true));
assertThat(context.getPluginsPath().resolve("android-studio").resolve("mockedPlugin").resolve("dev").resolve("MockedClass.class")).exists();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ void testAwsPrintHelp() {

private void checkInstallation(IdeTestContext context) {

assertThat(context.getSoftwarePath().resolve("aws/.ide.software.version")).exists().hasContent("2.24.15");
Aws commandlet = new Aws(context);
assertThat(commandlet.getInstalledVersion().toString()).isEqualTo("2.24.15");
assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed aws in version 2.24.15");
assertThat(context.getConfPath().resolve(PROJECT_AWS)).exists();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.devonfw.tools.ide.tool.eclipse;

import java.io.IOException;
import java.nio.file.Path;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
Expand Down Expand Up @@ -42,14 +41,13 @@ void testEclipse(String os) throws IOException {
eclipse.run();

// assert
Path eclipsePath = context.getSoftwarePath().resolve("eclipse");
assertThat(eclipsePath.resolve(".ide.software.version")).exists().hasContent("2024-09");
assertThat(eclipse.getInstalledVersion().toString()).isEqualTo("2024-09");
assertThat(context).log().hasEntries(
new IdeLogEntry(IdeLogLevel.SUCCESS, "Successfully installed java in version 17.0.10_7", true),
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 @@ -198,7 +198,8 @@ void testIntellijMvnAndGradleRepositoryImport() {

private void checkInstallation(IdeTestContext context) {

assertThat(context.getSoftwarePath().resolve("intellij/.ide.software.version")).exists().hasContent("2023.3.3");
Intellij commandlet = context.getCommandletManager().getCommandlet(Intellij.class);
assertThat(commandlet.getInstalledVersion().toString()).isEqualTo("2023.3.3");
assertThat(context.getWorkspacePath().resolve("idea.properties")).exists();
assertThat(context).log().hasEntries(
new IdeLogEntry(IdeLogLevel.SUCCESS, "Successfully installed java in version 17.0.10_7", true),
Expand Down
6 changes: 4 additions & 2 deletions cli/src/test/java/com/devonfw/tools/ide/tool/jmc/JmcTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,11 @@ 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");
Jmc jmcCommandlet = context.getCommandletManager().getCommandlet(Jmc.class);
assertThat(jmcCommandlet.getInstalledVersion().toString()).isEqualTo("8.3.0");
assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed jmc in version 8.3.0");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ void testCheckEditionConflictInstallation() {

private void checkInstallation(IdeTestContext context) {

assertThat(context.getSoftwarePath().resolve("pycharm/.ide.software.version")).exists().hasContent("2024.3.5");
Pycharm commandlet = context.getCommandletManager().getCommandlet(Pycharm.class);
assertThat(commandlet.getInstalledVersion().toString()).isEqualTo("2024.3.5");
assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed pycharm in version 2024.3.5");
assertThat(context).logAtDebug().hasMessage("Omitting installation of inactive plugin InactivePlugin (inactivePlugin).");
assertThat(context).logAtSuccess().hasMessage("Successfully ended step 'Install plugin ActivePlugin'.");
Expand Down
Loading