Repository: beeware/briefcase

Issue:
Create Windows executable instead of MSI
Hi, I have two questions: 
Is it possible to create a self-contained Windows executable file instead of an installable version? Basically I'm looking for something similar to how the packaging is done already for linux.
If it isn't at the moment, will it ever be possible? Thanks.

Discussion:
MarkusPiotrowski: Dear @freakboy3742 ,

Thank you very much for your detailed review and I'm sorry for confronting you with a PR with so much issues. One of the problems I had was that I had a running implementation on briefcase 0.3.12 (where app signing and distribution folders didn't exist) but then I was pushed back with each new release of briefcase since they had some significant changes for Windows packaging. Most of the time for this PR I spend trying to understand the testing environment (and obviously I did not well), for two days I believed that my implementation is not longer working because the tests failed until I realized that the implementation was OK and the issues were 'only' the tests... :-)
I hope I can address all the issues you mentioned, I'll try it.
freakboy3742: > Thank you very much for your detailed review and I'm sorry for confronting you with a PR with so much issues. 

Not a problem at all - and although there's a lot of comments, I don't consider the issues to be *that* bad - it's lots of little things that are, in many cases, wrapped up in details that you probably don't know about unless you've been soaking in the code for years like I have. This is a really good first-time project contribution - the fact that you've submitted a PR that fully passes CI on your first attempt (even if there's room for improving those tests) puts you at the top of the class in terms of contributions :-)


Patch:
diff --git a/changes/457.feature.rst b/changes/457.feature.rst
new file mode 100644
index 00000000..9428e829
--- /dev/null
+++ b/changes/457.feature.rst
@@ -0,0 +1,1 @@
+Support for packaging Windows apps as simple ZIP files was added.
diff --git a/docs/reference/platforms/windows/app.rst b/docs/reference/platforms/windows/app.rst
index c65176a0..22e743f1 100644
--- a/docs/reference/platforms/windows/app.rst
+++ b/docs/reference/platforms/windows/app.rst
@@ -3,7 +3,11 @@ Windows App
 ===========
 
 A Windows app is a stub binary, allow with a collection of folders that contain
-the Python code for the app and the Python runtime libraries.
+the Python code for the app and the Python runtime libraries. Briefcase supports
+two packaging formats for a Windows app:
+
+1. As an MSI installer
+2. As a ZIP file containing all files needed to run the app
 
 Briefcase uses the `WiX Toolset <https://wixtoolset.org/>`__ to build an MSI
 installer for a Windows App. WiX, in turn, requires that .NET Framework 3.5 is
diff --git a/docs/reference/platforms/windows/index.rst b/docs/reference/platforms/windows/index.rst
index 60e84a55..c652ff00 100644
--- a/docs/reference/platforms/windows/index.rst
+++ b/docs/reference/platforms/windows/index.rst
@@ -7,7 +7,8 @@ The default output format for Windows is an :doc:`app <./app>`.
 Briefcase also supports creating a :doc:`Visual Studio project <./visualstudio>`
 which in turn can be used to build an app.
 
-Both output formats support packaging as an MSI.
+Both output formats support packaging as a Microsoft Software Installer (MSI) or
+as a ZIP file.
 
 .. toctree::
    :maxdepth: 1
diff --git a/docs/reference/platforms/windows/visualstudio.rst b/docs/reference/platforms/windows/visualstudio.rst
index 1e4b704a..9909fac3 100644
--- a/docs/reference/platforms/windows/visualstudio.rst
+++ b/docs/reference/platforms/windows/visualstudio.rst
@@ -28,6 +28,11 @@ ensure that you have installed the following:
   - All default packages
   - C++/CLI support for v143 build tools
 
+Briefcase supports two packaging formats for a Windows App:
+
+1. As an MSI installer
+2. As a ZIP file containing all files needed to run the app
+
 Briefcase uses the `WiX Toolset <https://wixtoolset.org/>`__ to build an MSI
 installer for a Windows App. WiX, in turn, requires that .NET Framework 3.5 is
 enabled. To ensure .NET Framework 3.5 is enabled:
diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py
index cb1c8c06..3ea3c4ab 100644
--- a/src/briefcase/platforms/windows/__init__.py
+++ b/src/briefcase/platforms/windows/__init__.py
@@ -2,8 +2,9 @@ import os
 import re
 import subprocess
 import uuid
-from pathlib import Path
+from pathlib import Path, PurePath
 from typing import List
+from zipfile import ZIP_DEFLATED, ZipFile
 
 from briefcase.commands import CreateCommand, PackageCommand, RunCommand
 from briefcase.config import AppConfig, parsed_version
@@ -23,7 +24,8 @@ class WindowsMixin:
         return self.bundle_path(app) / self.packaging_root / f"{app.formal_name}.exe"
 
     def distribution_path(self, app):
-        return self.dist_path / f"{app.formal_name}-{app.version}.msi"
+        suffix = "zip" if app.packaging_format == "zip" else "msi"
+        return self.dist_path / f"{app.formal_name}-{app.version}.{suffix}"
 
 
 class WindowsCreateCommand(CreateCommand):
@@ -118,7 +120,7 @@ class WindowsRunCommand(RunCommand):
 class WindowsPackageCommand(PackageCommand):
     @property
     def packaging_formats(self):
-        return ["msi"]
+        return ["msi", "zip"]
 
     @property
     def default_packaging_format(self):
@@ -275,6 +277,22 @@ class WindowsPackageCommand(PackageCommand):
             )
             self.sign_file(app=app, filepath=self.binary_path(app), **sign_options)
 
+        if app.packaging_format == "zip":
+            self._package_zip(app)
+        else:
+            self._package_msi(app)
+
+            if sign_app:
+                self.logger.info("Signing MSI...", prefix=app.app_name)
+                self.sign_file(
+                    app=app,
+                    filepath=self.distribution_path(app),
+                    **sign_options,
+                )
+
+    def _package_msi(self, app):
+        """Build the msi installer."""
+
         self.logger.info("Building MSI...", prefix=app.app_name)
         try:
             self.logger.info("Compiling application manifest...")
@@ -354,10 +372,16 @@ class WindowsPackageCommand(PackageCommand):
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(f"Unable to link app {app.app_name}.") from e
 
-        if sign_app:
-            self.logger.info("Signing MSI...", prefix=app.app_name)
-            self.sign_file(
-                app=app,
-                filepath=self.distribution_path(app),
-                **sign_options,
-            )
+    def _package_zip(self, app):
+        """Package the app as simple zip file."""
+
+        self.logger.info("Building zip file...", prefix=app.app_name)
+        with self.input.wait_bar("Packing..."):
+            source = self.bundle_path(app) / self.packaging_root  # /src
+            zip_root = f"{app.formal_name}-{app.version}"
+
+            with ZipFile(self.distribution_path(app), "w", ZIP_DEFLATED) as archive:
+                for file_path in source.glob("**/*"):
+                    archive.write(
+                        file_path, zip_root / PurePath(file_path).relative_to(source)
+                    )
diff --git a/src/briefcase/platforms/windows/app.py b/src/briefcase/platforms/windows/app.py
index 0a4239c4..35613288 100644
--- a/src/briefcase/platforms/windows/app.py
+++ b/src/briefcase/platforms/windows/app.py
@@ -133,7 +133,7 @@ class WindowsAppRunCommand(WindowsAppMixin, WindowsRunCommand):
 
 
 class WindowsAppPackageCommand(WindowsAppMixin, WindowsPackageCommand):
-    description = "Package a Windows app as an MSI."
+    description = "Package a Windows app."
 
 
 class WindowsAppPublishCommand(WindowsAppMixin, PublishCommand):
diff --git a/src/briefcase/platforms/windows/visualstudio.py b/src/briefcase/platforms/windows/visualstudio.py
index b8431a0b..638fc85f 100644
--- a/src/briefcase/platforms/windows/visualstudio.py
+++ b/src/briefcase/platforms/windows/visualstudio.py
@@ -75,7 +75,7 @@ class WindowsVisualStudioPackageCommand(
     WindowsVisualStudioMixin,
     WindowsPackageCommand,
 ):
-    description = "Package a Visual Studio project as an MSI."
+    description = "Package a Visual Studio project."
 
 
 class WindowsVisualStudioPublishCommand(WindowsVisualStudioMixin, PublishCommand):


Test patch:
diff --git a/tests/platforms/windows/app/test_package.py b/tests/platforms/windows/app/test_package.py
index 9066b4a1..159f83a5 100644
--- a/tests/platforms/windows/app/test_package.py
+++ b/tests/platforms/windows/app/test_package.py
@@ -1,5 +1,6 @@
 from subprocess import CalledProcessError
 from unittest import mock
+from zipfile import ZipFile
 
 import pytest
 
@@ -10,6 +11,8 @@ from briefcase.integrations.windows_sdk import WindowsSDK
 from briefcase.integrations.wix import WiX
 from briefcase.platforms.windows.app import WindowsAppPackageCommand
 
+from ....utils import create_file
+
 
 @pytest.fixture
 def package_command(tmp_path):
@@ -30,9 +33,37 @@ def package_command(tmp_path):
     return command
 
 
+@pytest.fixture
+def package_command_with_files(package_command, tmp_path):
+    # Build the paths for the source and the distribution folders:
+    src_path = (
+        tmp_path / "base_path" / "build" / "first-app" / "windows" / "app" / "src"
+    )
+    dist_path = tmp_path / "base_path" / "dist"
+    src_path.mkdir(parents=True)
+    dist_path.mkdir(parents=True)
+
+    # Mock some typical folders and files in the src folder:
+    files = (
+        src_path / "First App.exe",
+        src_path / "python.exe",
+        src_path / "python3.dll",
+        src_path / "vcruntime140.dll",
+        src_path / "app/first-app" / "app.py",
+        src_path / "app/first-app" / "resources" / "__init__.py",
+        src_path / "app/first-app-0.0.1.dist-info" / "top_level.txt",
+        src_path / "app_packages" / "clr.py",
+        src_path / "app_packages" / "toga_winforms" / "command.py",
+    )
+    for file in files:
+        create_file(file, "")
+
+    return package_command
+
+
 def test_package_formats(package_command):
     """Packaging formats are as expected."""
-    assert package_command.packaging_formats == ["msi"]
+    assert package_command.packaging_formats == ["msi", "zip"]
     assert package_command.default_packaging_format == "msi"
 
 
@@ -207,6 +238,46 @@ def test_package_msi(package_command, first_app_config, tmp_path):
     ]
 
 
+def test_package_zip(package_command_with_files, first_app_config, tmp_path):
+    """A Windows app can be packaged as a zip file."""
+
+    first_app_config.packaging_format = "zip"
+    package_command_with_files.package_app(first_app_config)
+
+    archive_file = tmp_path / "base_path" / "dist" / "First App-0.0.1.zip"
+    source_folders_and_files = (
+        "app/",
+        "app/first-app/",
+        "app/first-app/resources/",
+        "app/first-app-0.0.1.dist-info/",
+        "app_packages/",
+        "app_packages/toga_winforms/",
+        "First App.exe",
+        "python.exe",
+        "python3.dll",
+        "vcruntime140.dll",
+        "app/first-app/app.py",
+        "app/first-app/resources/__init__.py",
+        "app/first-app-0.0.1.dist-info/top_level.txt",
+        "app_packages/clr.py",
+        "app_packages/toga_winforms/command.py",
+    )
+
+    # The zip file exists
+    assert archive_file.exists()
+
+    # Check content of zip file
+    with ZipFile(archive_file) as archive:
+        root = "First App-0.0.1/"
+        # All folders and files in zip are from source
+        for name in archive.namelist():
+            # name.removeprefix(f'{root}') will only work in Python > 3.8
+            assert name[len(root) :] in source_folders_and_files
+        # All files from source are in zip
+        for file in source_folders_and_files:
+            assert f"{root}{file}" in archive.namelist()
+
+
 @pytest.mark.parametrize(
     "use_local_machine, additional_args",
     [(False, []), (True, ["-sm"])],
@@ -362,6 +433,73 @@ def test_package_msi_with_codesigning(
     ]
 
 
+@pytest.mark.parametrize(
+    "use_local_machine, additional_args",
+    [(False, []), (True, ["-sm"])],
+)
+def test_package_zip_with_codesigning(
+    package_command_with_files,
+    first_app_config,
+    tmp_path,
+    use_local_machine,
+    additional_args,
+):
+    """In a ZIP package, only the binary will be code signed."""
+
+    first_app_config.packaging_format = "zip"
+
+    package_command_with_files.package_app(
+        first_app_config,
+        identity="80ee4c3321122916f5637522451993c2a0a4a56a",
+        file_digest="sha42",
+        use_local_machine=use_local_machine,
+        cert_store="mystore",
+        timestamp_url="http://freetimestamps.com",
+        timestamp_digest="sha56",
+    )
+
+    assert package_command_with_files.tools.subprocess.run.mock_calls == [
+        # Codesign app exe
+        mock.call(
+            [
+                tmp_path
+                / "windows_sdk"
+                / "bin"
+                / "81.2.1.0"
+                / "groovy"
+                / "signtool.exe",
+                "sign",
+                "-s",
+                "mystore",
+                "-sha1",
+                "80ee4c3321122916f5637522451993c2a0a4a56a",
+                "-fd",
+                "sha42",
+                "-d",
+                "The first simple app \\ demonstration",
+                "-du",
+                "https://example.com/first-app",
+                "-tr",
+                "http://freetimestamps.com",
+                "-td",
+                "sha56",
+            ]
+            + additional_args
+            + [
+                tmp_path
+                / "base_path"
+                / "build"
+                / "first-app"
+                / "windows"
+                / "app"
+                / "src"
+                / "First App.exe"
+            ],
+            check=True,
+        ),
+    ]
+
+
 def test_package_msi_invalid_identity(package_command, first_app_config):
     """Codesigning fails, along with packaging, if the identity is invalid."""
     with pytest.raises(
diff --git a/tests/platforms/windows/conftest.py b/tests/platforms/windows/conftest.py
new file mode 100644
index 00000000..edf30cbf
--- /dev/null
+++ b/tests/platforms/windows/conftest.py
@@ -0,0 +1,8 @@
+import pytest
+
+
+# Windows' AppConfig requires attribute 'packaging_format'
+@pytest.fixture
+def first_app_config(first_app_config):
+    first_app_config.packaging_format = "msi"
+    return first_app_config