diff --git a/changes/1000.bugfix.rst b/changes/1000.bugfix.rst new file mode 100644 index 000000000..84cb06722 --- /dev/null +++ b/changes/1000.bugfix.rst @@ -0,0 +1 @@ +On MacOS, Rosetta is now installed automatically if needed. diff --git a/src/briefcase/integrations/java.py b/src/briefcase/integrations/java.py index f0d90c7d2..3952352e3 100644 --- a/src/briefcase/integrations/java.py +++ b/src/briefcase/integrations/java.py @@ -69,6 +69,10 @@ def verify(cls, tools: ToolCache, install=True): java_home = tools.os.environ.get("JAVA_HOME", "") install_message = None + if tools.host_arch == "arm64" and tools.host_os == "Darwin": + # Java 8 is not available for MacOS on ARM64, so we will require Rosetta. + cls.verify_rosetta(tools) + # macOS has a helpful system utility to determine JAVA_HOME. Try it. if not java_home and tools.host_os == "Darwin": try: @@ -284,3 +288,24 @@ def upgrade(self): self.uninstall() self.install() + + @classmethod + def verify_rosetta(cls, tools): + try: + tools.subprocess.check_output( + ["arch", "-x86_64", "true"], stderr=subprocess.STDOUT + ) + except subprocess.CalledProcessError: + tools.logger.info( + """\ +This command requires Rosetta, but it does not appear to be installed. Briefcase will +attempt to install it now. +""" + ) + try: + tools.subprocess.run( + ["softwareupdate", "--install-rosetta", "--agree-to-license"], + check=True, + ) + except subprocess.CalledProcessError as e: + raise BriefcaseCommandError("Failed to install Rosetta") from e diff --git a/tests/integrations/java/test_JDK__verify.py b/tests/integrations/java/test_JDK__verify.py index 1074f961c..4aecf794a 100644 --- a/tests/integrations/java/test_JDK__verify.py +++ b/tests/integrations/java/test_JDK__verify.py @@ -2,6 +2,7 @@ import shutil import subprocess from pathlib import Path +from subprocess import CalledProcessError from unittest import mock import pytest @@ -9,6 +10,12 @@ from briefcase.exceptions import BriefcaseCommandError, MissingToolError, NetworkFailure from briefcase.integrations.java import JDK +CALL_JAVA_HOME = mock.call(["/usr/libexec/java_home"], stderr=subprocess.STDOUT) +CALL_ROSETTA_CHECK = mock.call(["arch", "-x86_64", "true"], stderr=subprocess.STDOUT) +CALL_ROSETTA_INSTALL = mock.call( + ["softwareupdate", "--install-rosetta", "--agree-to-license"], check=True +) + def test_short_circuit(mock_tools): """Tool is not created if already cached.""" @@ -25,6 +32,9 @@ def test_macos_tool_java_home(mock_tools, capsys): # Mock being on macOS mock_tools.host_os = "Darwin" + # Prevent Rosetta check. + mock_tools.host_arch = "x86_64" + # Mock 2 calls to check_output. mock_tools.subprocess.check_output.side_effect = [ "/path/to/java", @@ -37,20 +47,14 @@ def test_macos_tool_java_home(mock_tools, capsys): # The JDK should have the path returned by the tool assert mock_tools.java.java_home == Path("/path/to/java") - mock_tools.subprocess.check_output.assert_has_calls( - [ - # First call is to /usr/lib/java_home - mock.call( - ["/usr/libexec/java_home"], - stderr=subprocess.STDOUT, - ), - # Second is a call to verify a valid Java version - mock.call( - [os.fsdecode(Path("/path/to/java/bin/javac")), "-version"], - stderr=subprocess.STDOUT, - ), - ] - ) + assert mock_tools.subprocess.check_output.mock_calls == [ + CALL_JAVA_HOME, + # Second is a call to verify a valid Java version + mock.call( + [os.fsdecode(Path("/path/to/java/bin/javac")), "-version"], + stderr=subprocess.STDOUT, + ), + ] # No console output output = capsys.readouterr() @@ -63,6 +67,9 @@ def test_macos_tool_failure(mock_tools, tmp_path, capsys): # Mock being on macOS mock_tools.host_os = "Darwin" + # Prevent Rosetta check. + mock_tools.host_arch = "x86_64" + # Mock a failed call on the libexec tool mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError( returncode=1, cmd="/usr/libexec/java_home" @@ -77,15 +84,7 @@ def test_macos_tool_failure(mock_tools, tmp_path, capsys): # The JDK should have the briefcase JAVA_HOME assert jdk.java_home == tmp_path / "tools" / "java" / "Contents" / "Home" - mock_tools.subprocess.check_output.assert_has_calls( - [ - # First call is to /usr/lib/java_home - mock.call( - ["/usr/libexec/java_home"], - stderr=subprocess.STDOUT, - ), - ] - ) + assert mock_tools.subprocess.check_output.mock_calls == [CALL_JAVA_HOME] # No console output output = capsys.readouterr() @@ -98,6 +97,9 @@ def test_macos_provided_overrides_tool_java_home(mock_tools, capsys): # Mock being on macOS mock_tools.host_os = "Darwin" + # Prevent Rosetta check. + mock_tools.host_arch = "x86_64" + # Setup explicit JAVA_HOME mock_tools.os.environ = {"JAVA_HOME": "/path/to/java"} @@ -124,6 +126,9 @@ def test_macos_provided_overrides_tool_java_home(mock_tools, capsys): def test_valid_provided_java_home(mock_tools, capsys): """If a valid JAVA_HOME is provided, it is used.""" + # Prevent Rosetta check. + mock_tools.host_arch = "x86_64" + # Setup explicit JAVA_HOME mock_tools.os.environ = {"JAVA_HOME": "/path/to/java"} @@ -162,6 +167,9 @@ def test_invalid_jdk_version(mock_tools, host_os, java_home, tmp_path, capsys): # Mock os mock_tools.host_os = host_os + # Prevent Rosetta check. + mock_tools.host_arch = "x86_64" + # Setup explicit JAVA_HOME mock_tools.os.environ = {"JAVA_HOME": "/path/to/java"} @@ -203,6 +211,9 @@ def test_no_javac(mock_tools, host_os, java_home, tmp_path, capsys): # Mock os mock_tools.host_os = host_os + # Prevent Rosetta check. + mock_tools.host_arch = "x86_64" + # Setup explicit JAVA_HOME mock_tools.os.environ = {"JAVA_HOME": "/path/to/nowhere"} @@ -243,6 +254,9 @@ def test_javac_error(mock_tools, host_os, java_home, tmp_path, capsys): # Mock os mock_tools.host_os = host_os + # Prevent Rosetta check. + mock_tools.host_arch = "x86_64" + # Setup explicit JAVA_HOME mock_tools.os.environ = {"JAVA_HOME": "/path/to/nowhere"} @@ -285,6 +299,9 @@ def test_unparseable_javac_version(mock_tools, host_os, java_home, tmp_path, cap # Mock os mock_tools.host_os = host_os + # Prevent Rosetta check. + mock_tools.host_arch = "x86_64" + # Setup explicit JAVA_HOME mock_tools.os.environ = {"JAVA_HOME": "/path/to/nowhere"} @@ -455,3 +472,98 @@ def test_invalid_jdk_archive(mock_tools, tmp_path): ) # The original archive was not deleted assert archive.unlink.call_count == 0 + + +def test_rosetta_host_os(mock_tools, tmp_path): + """On an OS other than macOS, the Rosetta check does not occur.""" + mock_tools.host_os = "Linux" + mock_tools.host_arch = "arm64" + + # Create a mock of a previously installed Java version. + (tmp_path / "tools" / "java" / "bin").mkdir(parents=True) + + JDK.verify(mock_tools) + mock_tools.subprocess.check_output.assert_not_called() + mock_tools.subprocess.run.assert_not_called() + + +def test_rosetta_host_arch(mock_tools, tmp_path): + """On an architecture other than ARM64, the Rosetta check does not + occur.""" + mock_tools.host_os = "Darwin" + mock_tools.host_arch = "x86_64" + + mock_tools.subprocess.check_output.side_effect = [ + CalledProcessError(1, "java_home") + ] + + # Create a mock of a previously installed Java version. + (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True) + + JDK.verify(mock_tools) + assert mock_tools.subprocess.check_output.mock_calls == [CALL_JAVA_HOME] + mock_tools.subprocess.run.assert_not_called() + + +def test_rosetta_already_installed(mock_tools, tmp_path): + """On an ARM Mac, the Rosetta check occurs before calling any other Java + commands.""" + mock_tools.host_os = "Darwin" + mock_tools.host_arch = "arm64" + + mock_tools.subprocess.check_output.side_effect = [ + None, # Rosetta check succeeds. + CalledProcessError(1, "java_home"), + ] + + # Create a mock of a previously installed Java version. + (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True) + + JDK.verify(mock_tools) + assert mock_tools.subprocess.check_output.mock_calls == [ + CALL_ROSETTA_CHECK, + CALL_JAVA_HOME, + ] + mock_tools.subprocess.run.assert_not_called() + + +def test_rosetta_install_success(mock_tools, tmp_path): + """Rosetta is installed if necessary.""" + mock_tools.host_os = "Darwin" + mock_tools.host_arch = "arm64" + + mock_tools.subprocess.check_output.side_effect = [ + CalledProcessError(1, "arch"), + CalledProcessError(1, "java_home"), + ] + + # Create a mock of a previously installed Java version. + (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True) + + JDK.verify(mock_tools) + assert mock_tools.subprocess.check_output.mock_calls == [ + CALL_ROSETTA_CHECK, + CALL_JAVA_HOME, + ] + assert mock_tools.subprocess.run.mock_calls == [CALL_ROSETTA_INSTALL] + + +def test_rosetta_install_failure(mock_tools, tmp_path): + """If Rosetta install fails, no Java commands are called.""" + mock_tools.host_os = "Darwin" + mock_tools.host_arch = "arm64" + + mock_tools.subprocess.check_output.side_effect = [ + CalledProcessError(1, "arch"), + ] + mock_tools.subprocess.run.side_effect = [ + CalledProcessError(1, "softwareupdate"), + ] + + # Create a mock of a previously installed Java version. + (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True) + + with pytest.raises(BriefcaseCommandError, match="Failed to install Rosetta"): + JDK.verify(mock_tools) + assert mock_tools.subprocess.check_output.mock_calls == [CALL_ROSETTA_CHECK] + assert mock_tools.subprocess.run.mock_calls == [CALL_ROSETTA_INSTALL]