diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 263dd3fa4a..3f851dc384 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,7 +235,7 @@ jobs: fail-fast: false matrix: framework: [ "toga", "pyside6", "pygame", "console" ] - runner-os: [ "macos-15-intel", "macos-latest", "ubuntu-24.04", "ubuntu-24.04-arm", "windows-latest" ] + runner-os: [ "macos-15-intel", "macos-latest", "ubuntu-24.04", "ubuntu-24.04-arm", "windows-latest", "windows-11-arm"] verify-apps: name: Build app @@ -250,4 +250,4 @@ jobs: fail-fast: false matrix: framework: [ "toga", "pyside6", "pygame", "console" ] - runner-os: [ "macos-15-intel", "macos-15", "ubuntu-24.04", "ubuntu-24.04-arm", "windows-latest" ] + runner-os: [ "macos-15-intel", "macos-15", "ubuntu-24.04", "ubuntu-24.04-arm", "windows-latest", "windows-11-arm" ] diff --git a/changes/1887.feature.md b/changes/1887.feature.md new file mode 100644 index 0000000000..76117f8715 --- /dev/null +++ b/changes/1887.feature.md @@ -0,0 +1 @@ +Briefcase can now build Windows apps for ARM64 devices. diff --git a/docs/en/reference/platforms/index.md b/docs/en/reference/platforms/index.md index b3aa540f32..439ad98711 100644 --- a/docs/en/reference/platforms/index.md +++ b/docs/en/reference/platforms/index.md @@ -153,7 +153,7 @@ {{ ci_tested }} - +{{ ci_tested }} @@ -165,7 +165,7 @@ {{ ci_tested }} - +{{ ci_tested }} diff --git a/docs/en/reference/platforms/windows/index.md b/docs/en/reference/platforms/windows/index.md index 8c3e0f3abc..aac47285eb 100644 --- a/docs/en/reference/platforms/windows/index.md +++ b/docs/en/reference/platforms/windows/index.md @@ -41,7 +41,7 @@ {{ ci_tested }} - +{{ ci_tested }} diff --git a/docs/en/reference/platforms/windows/visualstudio.md b/docs/en/reference/platforms/windows/visualstudio.md index 2d92bf0ba6..29cf43c432 100644 --- a/docs/en/reference/platforms/windows/visualstudio.md +++ b/docs/en/reference/platforms/windows/visualstudio.md @@ -60,7 +60,7 @@ All Windows apps, regardless of output format, use the same icon formats, have t ## Pre-requisites -Building the Visual Studio project requires that you install Visual Studio 2022 or later. Visual Studio 2022 Community Edition [can be downloaded for free from Microsoft](https://visualstudio.microsoft.com/vs/community/). You can also use the Professional or Enterprise versions if you have them. +Building the Visual Studio project requires that you install Visual Studio. Visual Studio Community Edition [can be downloaded for free from Microsoft](https://visualstudio.microsoft.com/vs/community/). You can also use the Professional or Enterprise versions if you have them. Briefcase will auto-detect the location of your Visual Studio installation, provided one of the following three things are true: @@ -75,6 +75,7 @@ When you install Visual Studio, there are many optional components. You should e - Desktop Development with C++ - All default packages - C++/CLI support for v143 build tools + - MSVC v143 VS 2022 C++ ARM64/x64 build tools ## Application configuration diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 8935009eaf..3c4387b701 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -237,3 +237,4 @@ Xauth Xcode Xr Xs +MSVC diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py index 937bbf8f04..d0e9244749 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -123,7 +123,15 @@ def support_package_url(self, support_revision: str) -> str: def stub_binary_filename(self, support_revision: str, is_console_app: bool) -> str: """The filename for the stub binary.""" stub_type = "Console" if is_console_app else "GUI" - return f"{stub_type}-Stub-{self.python_version_tag}-b{support_revision}.zip" + win_suffix = ( + f"-{self.tools.host_arch.lower()}" + if self.tools.host_os == "Windows" + else "" + ) + return ( + f"{stub_type}-Stub-{self.python_version_tag}-b{support_revision}" + f"{win_suffix}.zip" + ) def stub_binary_url(self, support_revision: str, is_console_app: bool) -> str: """The URL of the stub binary to use for apps of this type.""" diff --git a/src/briefcase/integrations/base.py b/src/briefcase/integrations/base.py index 2b412bf586..753ff9f817 100644 --- a/src/briefcase/integrations/base.py +++ b/src/briefcase/integrations/base.py @@ -195,8 +195,8 @@ def __init__( self.base_path = Path(base_path) self.home_path = Path(os.path.expanduser(home_path or Path.home())) - self.host_arch = self.platform.machine() self.host_os = self.platform.system() + self.host_arch = self._get_host_arch() # Python is 32bit if its pointers can only address with 32 bits or fewer self.is_32bit_python = self.sys.maxsize <= 2**32 @@ -208,6 +208,36 @@ def __init__( ) ) + def _get_host_arch(self) -> str: + arch = self.platform.machine() + # On Windows with Python < 3.12, ``platform.machine()`` returns the + # emulated architecture (e.g. "AMD64") when an x86-64 Python interpreter + # is running under ARM64. ``IsWow64Process2()`` exposes the + # native machine type. + if ( + arch == "AMD64" + and self.host_os == "Windows" + and self.sys.version_info < (3, 12) + and self.sys.getwindowsversion().build >= 16299 # Windows 10 1709 + ): + import ctypes + from ctypes import wintypes + + IMAGE_FILE_MACHINE_ARM64 = 0xAA64 + kernel32 = ctypes.windll.kernel32 + process_machine = ctypes.c_ushort(0) + native_machine = ctypes.c_ushort(0) + if ( + kernel32.IsWow64Process2( + wintypes.HANDLE(kernel32.GetCurrentProcess()), + ctypes.byref(process_machine), + ctypes.byref(native_machine), + ) + and native_machine.value == IMAGE_FILE_MACHINE_ARM64 + ): + return "ARM64" + return arch + @cached_property def system_encoding(self) -> str: """The character encoding for the system's locale. diff --git a/src/briefcase/integrations/visualstudio.py b/src/briefcase/integrations/visualstudio.py index ace35185a4..e42d13f961 100644 --- a/src/briefcase/integrations/visualstudio.py +++ b/src/briefcase/integrations/visualstudio.py @@ -18,7 +18,7 @@ class VisualStudio(Tool): - Default packages * Desktop Development with C++ - Default packages; plus - - C++/CLI support for v143 build tools + - MSVC v143 VS 2022 C++ ARM64/x64 build tools """ def __init__( diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index 9ae3cd5ff0..ffa6370051 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -88,12 +88,24 @@ def distribution_path(self, app): suffix = "zip" if app.packaging_format == "zip" else "msi" return self.dist_path / f"{app.formal_name}-{app.version}.{suffix}" + @property + def vscode_platform(self): + return "ARM64" if self.tools.host_arch == "ARM64" else "x64" + def verify_host(self): super().verify_host() - # The stub app only supports x86-64 right now, and our VisualStudio and WiX code - # is the same (#1887). However, we can package an external x86-64 app on any - # build machine. - if self.tools.host_arch != "AMD64": + if ( + self.tools.host_arch == "ARM64" + and "AMD64" in self.tools.platform.python_compiler() + ): + raise UnsupportedHostError( + "The Python interpreter that is being used to run Briefcase has been " + "compiled for x86_64, and is running in emulation mode on ARM64 " + "hardware. You must use a Python interpreter that has been " + "compiled for ARM64." + ) + + if self.tools.host_arch not in ("AMD64", "ARM64"): if all(app.external_package_path for app in self.apps.values()): if not self.is_clone: self.console.warning(f""" @@ -101,11 +113,11 @@ def verify_host(self): ** WARNING: Possible architecture mismatch ** ************************************************************************* -The build machine is {self.tools.host_arch}, but Briefcase on Windows currently only -supports x86-64 installers. +The build machine is {self.tools.host_arch}, but Briefcase on Windows only +supports x86-64 and ARM64 installers. You are responsible for ensuring that the content of external_package_path -is compatible with x86-64. +is compatible with supported platforms. ************************************************************************* """) @@ -127,7 +139,8 @@ def verify_host(self): class WindowsCreateCommand(CreateCommand): def support_package_filename(self, support_revision): - return f"python-{self.python_version_tag}.{support_revision}-embed-amd64.zip" + arch = self.tools.host_arch.lower() + return f"python-{self.python_version_tag}.{support_revision}-embed-{arch}.zip" def support_package_url(self, support_revision): micro = re.match(r"\d+", str(support_revision)).group(0) @@ -594,7 +607,7 @@ def _package_msi(self, app): "-ext", self.tools.wix.ext_path("UI"), "-arch", - "x64", # Default is x86, regardless of the build machine. + self.vscode_platform.lower(), f"{app.app_name}.wxs", "-loc", "unicode.wxl", diff --git a/src/briefcase/platforms/windows/app.py b/src/briefcase/platforms/windows/app.py index 06dbd079fa..0559bdae95 100644 --- a/src/briefcase/platforms/windows/app.py +++ b/src/briefcase/platforms/windows/app.py @@ -23,9 +23,12 @@ class WindowsAppMixin(WindowsMixin): output_format = "app" - packaging_root = Path("src") supports_external_packaging = True + @property + def packaging_root(self): + return Path("src") + def project_path(self, app): return self.bundle_path(app) diff --git a/src/briefcase/platforms/windows/visualstudio.py b/src/briefcase/platforms/windows/visualstudio.py index b7275fe3b2..5e61a46357 100644 --- a/src/briefcase/platforms/windows/visualstudio.py +++ b/src/briefcase/platforms/windows/visualstudio.py @@ -21,7 +21,10 @@ class WindowsVisualStudioMixin(WindowsMixin): output_format = "VisualStudio" - packaging_root = Path("x64/Release") + + @property + def packaging_root(self): + return Path(f"{self.vscode_platform}/Release") def project_path(self, app): return self.bundle_path(app) / f"{app.formal_name}.sln" @@ -65,6 +68,7 @@ def build_app(self, app: BaseConfig, **kwargs): "-property:RestorePackagesConfig=true", f"-target:{app.formal_name}", "-property:Configuration=Release", + f"-property:Platform={self.vscode_platform}", ( "-verbosity:detailed" if self.console.is_deep_debug diff --git a/tests/integrations/base/test_ToolCache.py b/tests/integrations/base/test_ToolCache.py index 07f4fb3cba..feec097739 100644 --- a/tests/integrations/base/test_ToolCache.py +++ b/tests/integrations/base/test_ToolCache.py @@ -109,6 +109,72 @@ def test_host_arch_and_os(simple_tools): assert simple_tools.host_os == platform.system() +def test_get_host_arch_windows_arm64(dummy_console, monkeypatch, tmp_path): + """ARM64 is detected on Windows with a pre-3.12 Python x86_64 interpreter.""" + mock_ctypes = MagicMock() + mock_ctypes.c_ushort.return_value.value = 0xAA64 # IMAGE_FILE_MACHINE_ARM64 + + mock_platform = MagicMock() + mock_platform.machine.return_value = "AMD64" + mock_platform.system.return_value = "Windows" + + mock_sys = MagicMock() + mock_sys.version_info = (3, 11, 0) + mock_sys.getwindowsversion.return_value.build = 16299 + mock_sys.maxsize = 2**64 + + monkeypatch.setattr(ToolCache, "platform", mock_platform) + monkeypatch.setattr(ToolCache, "sys", mock_sys) + monkeypatch.setitem(sys.modules, "ctypes", mock_ctypes) + + tools = ToolCache(console=dummy_console, base_path=tmp_path) + + assert tools.host_arch == "ARM64" + + +def test_get_host_arch_windows_not_arm64(dummy_console, monkeypatch, tmp_path): + """AMD64 is returned when IsWow64Process2 reports the native machine is not + ARM64.""" + mock_ctypes = MagicMock() + mock_ctypes.windll.kernel32.IsWow64Process2.return_value = 0 + + mock_platform = MagicMock() + mock_platform.machine.return_value = "AMD64" + mock_platform.system.return_value = "Windows" + + mock_sys = MagicMock() + mock_sys.version_info = (3, 11, 0) + mock_sys.getwindowsversion.return_value.build = 16299 + mock_sys.maxsize = 2**64 + + monkeypatch.setattr(ToolCache, "platform", mock_platform) + monkeypatch.setattr(ToolCache, "sys", mock_sys) + monkeypatch.setitem(sys.modules, "ctypes", mock_ctypes) + + tools = ToolCache(console=dummy_console, base_path=tmp_path) + + assert tools.host_arch == "AMD64" + + +def test_get_host_arch_windows_python312(dummy_console, monkeypatch, tmp_path): + """On Python 3.12+, IsWow64Process2 is not called; platform.machine() is used + directly.""" + mock_platform = MagicMock() + mock_platform.machine.return_value = "AMD64" + mock_platform.system.return_value = "Windows" + + mock_sys = MagicMock() + mock_sys.version_info = (3, 12, 0) + mock_sys.maxsize = 2**64 + + monkeypatch.setattr(ToolCache, "platform", mock_platform) + monkeypatch.setattr(ToolCache, "sys", mock_sys) + + tools = ToolCache(console=dummy_console, base_path=tmp_path) + + assert tools.host_arch == "AMD64" + + def test_base_path_is_path(dummy_console, simple_tools): """Base path is always a Path.""" # The BaseCommand tests have much more extensive tests for this path. diff --git a/tests/platforms/windows/app/create/test_create.py b/tests/platforms/windows/app/create/test_create.py index 61afc1fe80..61b036e60e 100644 --- a/tests/platforms/windows/app/create/test_create.py +++ b/tests/platforms/windows/app/create/test_create.py @@ -1,4 +1,6 @@ +import platform import sys +from unittest.mock import MagicMock import pytest from packaging.version import Version @@ -28,9 +30,9 @@ def test_unsupported_host_os(create_command, host_os): create_command() -@pytest.mark.parametrize("host_arch", ["i686", "ARM64", "wonky"]) +@pytest.mark.parametrize("host_arch", ["i686", "wonky"]) def test_unsupported_arch(create_command, host_arch, first_app_config): - """Internal apps can only be developed on x86-64.""" + """Internal apps can only be developed on x86-64 and ARM64.""" create_command.tools.host_os = "Windows" create_command.tools.host_arch = host_arch create_command.apps["first"] = first_app_config @@ -42,7 +44,7 @@ def test_unsupported_arch(create_command, host_arch, first_app_config): create_command.verify_host() -@pytest.mark.parametrize("host_arch", ["i686", "ARM64", "wonky"]) +@pytest.mark.parametrize("host_arch", ["i686", "wonky"]) def test_unsupported_arch_external(create_command, host_arch, first_app_config, capsys): """External apps can be built on a different architecture, with a warning.""" create_command.tools.host_os = "Windows" @@ -82,6 +84,31 @@ def test_unsupported_32bit_python(create_command): create_command() +def test_verify_windows_cpu_arch(create_command): + """Running through x86_64 emulation on Windows ARM64 will raise an error.""" + + # Create a Mock object for the platform module + create_command.tools.platform = MagicMock(spec_set=platform) + + # Simulate that Mock platform is running on Windows ARM64 with an x86_64 Python interpreter + create_command.tools.host_os = "Windows" + create_command.tools.host_arch = "ARM64" + create_command.tools.platform.python_compiler = MagicMock( + return_value="MSV v.1950 64 bit (AMD64)" + ) + + with pytest.raises( + UnsupportedHostError, + match=( + r"The Python interpreter that is being used to run Briefcase has been " + r"compiled for x86_64, and is running in emulation mode on ARM64 " + r"hardware. You must use a Python interpreter that has been " + r"compiled for ARM64." + ), + ): + create_command.verify_host() + + def test_context(create_command, first_app_config): context = create_command.output_format_template_context(first_app_config) assert sorted(context.keys()) == [ @@ -162,7 +189,7 @@ def test_support_package_url( expected_link = ( f"https://www.python.org/ftp/python" f"/{sys.version_info.major}.{sys.version_info.minor}.{micro}" - f"/python-{sys.version_info.major}.{sys.version_info.minor}.{revision}-embed-amd64.zip" + f"/python-{sys.version_info.major}.{sys.version_info.minor}.{revision}-embed-{create_command.tools.host_arch.lower()}.zip" ) assert create_command.support_package_url(revision) == expected_link diff --git a/tests/platforms/windows/visualstudio/test_build.py b/tests/platforms/windows/visualstudio/test_build.py index 7c6681ed1f..7ca0327a3d 100644 --- a/tests/platforms/windows/visualstudio/test_build.py +++ b/tests/platforms/windows/visualstudio/test_build.py @@ -65,6 +65,7 @@ def test_build_app(build_command, first_app_config, tool_debug_mode, tmp_path): "-property:RestorePackagesConfig=true", "-target:First App", "-property:Configuration=Release", + f"-property:Platform={build_command.vscode_platform}", "-verbosity:detailed" if tool_debug_mode else "-verbosity:normal", ], check=True,