diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c72dd14f..eb32c6a11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,10 +119,14 @@ jobs: key: v1-tessdata-${{ hashFiles('./install/common/download-tessdata.py') }} - name: Run CLI tests run: poetry run make test - # Taken from: https://github.com/orgs/community/discussions/27149#discussioncomment-3254829 - - name: Set path for candle and light - run: echo "C:\Program Files (x86)\WiX Toolset v3.14\bin" >> $GITHUB_PATH - shell: bash + - name: Set up .NET CLI environment + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.x" + - name: Install WiX Toolset + run: dotnet tool install --global wix + - name: Add WiX UI extension + run: wix extension add --global WixToolset.UI.wixext - name: Build the MSI installer # NOTE: This also builds the .exe internally. run: poetry run .\install\windows\build-app.bat diff --git a/BUILD.md b/BUILD.md index 845b8ec59..b4f1c7f12 100644 --- a/BUILD.md +++ b/BUILD.md @@ -471,11 +471,24 @@ poetry shell .\dev_scripts\dangerzone.bat ``` -### If you want to build the installer +### If you want to build the Windows installer -* Go to https://dotnet.microsoft.com/download/dotnet-framework and download and install .NET Framework 3.5 SP1 Runtime. I downloaded `dotnetfx35.exe`. -* Go to https://wixtoolset.org/releases/ and download and install WiX toolset. I downloaded `wix314.exe`. -* Add `C:\Program Files (x86)\WiX Toolset v3.14\bin` to the path ([instructions](https://web.archive.org/web/20230221104142/https://windowsloop.com/how-to-add-to-windows-path/)). +Install [.NET SDK](https://dotnet.microsoft.com/en-us/download) version 6 or later. Then, open a terminal and install the latest version of [WiX Toolset .NET tool](https://wixtoolset.org/) **v5** with: + +```sh +dotnet tool install --global wix --version 5.* +``` + +Install the WiX UI extension. You may need to open a new terminal in order to use the newly installed `wix` .NET tool: + +```sh +wix extension add --global WixToolset.UI.wixext/5.x.y +``` + +> [!IMPORTANT] +> To avoid compatibility issues, ensure the WiX UI extension version matches the version of the WiX Toolset. +> +> Run `wix --version` to check the version of WiX Toolset you have installed and replace `5.x.y` with the full version number without the Git revision. ### If you want to sign binaries with Authenticode diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f149b48..3f879e81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ since 0.4.1, and this project adheres to [Semantic Versioning](https://semver.or - Platform support: Drop support for Fedora 39, since it's end-of-life ([#999](https://github.com/freedomofpress/dangerzone/pull/999)) +### Development changes + +- Build Dangerzone MSI with Wix Toolset 5 ([#929](https://github.com/freedomofpress/dangerzone/pull/929)). + Thanks [@jkarasti](https://github.com/jkarasti) for the contribution. + ## [0.8.0](https://github.com/freedomofpress/dangerzone/compare/v0.8.0...0.7.1) ### Added diff --git a/install/windows/build-app.bat b/install/windows/build-app.bat index 1d2b770ab..ea74429dc 100644 --- a/install/windows/build-app.bat +++ b/install/windows/build-app.bat @@ -17,22 +17,23 @@ signtool.exe sign /v /d "Dangerzone" /a /n "Freedom of the Press Foundation" /fd REM verify the signature of dangerzone-cli.exe signtool.exe verify /pa build\exe.win-amd64-3.12\dangerzone-cli.exe -REM build the wix file -python install\windows\build-wxs.py > build\Dangerzone.wxs +REM build the wxs file +python install\windows\build-wxs.py REM build the msi package cd build -candle.exe Dangerzone.wxs -light.exe -ext WixUIExtension Dangerzone.wixobj +wix build -arch x64 -ext WixToolset.UI.wixext .\Dangerzone.wxs -out Dangerzone.msi + +REM validate Dangerzone.msi +wix msi validate Dangerzone.msi REM code sign Dangerzone.msi -insignia.exe -im Dangerzone.msi signtool.exe sign /v /d "Dangerzone" /a /n "Freedom of the Press Foundation" /fd sha256 /t http://time.certum.pl/ Dangerzone.msi REM verify the signature of Dangerzone.msi signtool.exe verify /pa Dangerzone.msi -REM moving Dangerzone.msi to dist +REM move Dangerzone.msi to dist cd .. mkdir dist move build\Dangerzone.msi dist diff --git a/install/windows/build-wxs.py b/install/windows/build-wxs.py index 14aa92deb..33a4622bb 100644 --- a/install/windows/build-wxs.py +++ b/install/windows/build-wxs.py @@ -4,114 +4,75 @@ import xml.etree.ElementTree as ET -def build_data(dirname, dir_prefix, id_, name): +def build_data(base_path, path_prefix, dir_id, dir_name): data = { - "id": id_, - "name": name, + "directory_name": dir_name, + "directory_id": dir_id, "files": [], "dirs": [], } - for basename in os.listdir(dirname): - filename = os.path.join(dirname, basename) - if os.path.isfile(filename): - data["files"].append(os.path.join(dir_prefix, basename)) - elif os.path.isdir(filename): - if id_ == "INSTALLDIR": - id_prefix = "Folder" + if dir_id == "INSTALLFOLDER": + data["component_id"] = "ApplicationFiles" + else: + data["component_id"] = "Component" + dir_id + data["component_guid"] = str(uuid.uuid4()).upper() + + for entry in os.listdir(base_path): + entry_path = os.path.join(base_path, entry) + if os.path.isfile(entry_path): + data["files"].append(os.path.join(path_prefix, entry)) + elif os.path.isdir(entry_path): + if dir_id == "INSTALLFOLDER": + next_dir_prefix = "Folder" else: - id_prefix = id_ + next_dir_prefix = dir_id # Skip lib/PySide6/examples folder due to ilegal file names - if "\\build\\exe.win-amd64-3.12\\lib\\PySide6\\examples" in dirname: + if "\\build\\exe.win-amd64-3.12\\lib\\PySide6\\examples" in base_path: continue # Skip lib/PySide6/qml/QtQuick folder due to ilegal file names # XXX Since we're not using Qml it should be no problem - if "\\build\\exe.win-amd64-3.12\\lib\\PySide6\\qml\\QtQuick" in dirname: + if "\\build\\exe.win-amd64-3.12\\lib\\PySide6\\qml\\QtQuick" in base_path: continue - id_value = f"{id_prefix}{basename.capitalize().replace('-', '_')}" - data["dirs"].append( - build_data( - os.path.join(dirname, basename), - os.path.join(dir_prefix, basename), - id_value, - basename, - ) + next_dir_id = next_dir_prefix + entry.capitalize().replace("-", "_") + subdata = build_data( + os.path.join(base_path, entry), + os.path.join(path_prefix, entry), + next_dir_id, + entry, ) - if len(data["files"]) > 0: - if id_ == "INSTALLDIR": - data["component_id"] = "ApplicationFiles" - else: - data["component_id"] = "FolderComponent" + id_[len("Folder") :] - data["component_guid"] = str(uuid.uuid4()) + # Add the subdirectory only if it contains files or subdirectories + if subdata["files"] or subdata["dirs"]: + data["dirs"].append(subdata) return data -def build_dir_xml(root, data): +def build_directory_xml(root, data): attrs = {} - if "id" in data: - attrs["Id"] = data["id"] - if "name" in data: - attrs["Name"] = data["name"] - el = ET.SubElement(root, "Directory", attrs) + attrs["Id"] = data["directory_id"] + attrs["Name"] = data["directory_name"] + directory_el = ET.SubElement(root, "Directory", attrs) for subdata in data["dirs"]: - build_dir_xml(el, subdata) - - # If this is the ProgramMenuFolder, add the menu component - if "id" in data and data["id"] == "ProgramMenuFolder": - component_el = ET.SubElement( - el, - "Component", - Id="ApplicationShortcuts", - Guid="539e7de8-a124-4c09-aa55-0dd516aad7bc", - ) - ET.SubElement( - component_el, - "Shortcut", - Id="ApplicationShortcut1", - Name="Dangerzone", - Description="Dangerzone", - Target="[INSTALLDIR]dangerzone.exe", - WorkingDirectory="INSTALLDIR", - ) - ET.SubElement( - component_el, - "RegistryValue", - Root="HKCU", - Key="Software\Freedom of the Press Foundation\Dangerzone", - Name="installed", - Type="integer", - Value="1", - KeyPath="yes", - ) + build_directory_xml(directory_el, subdata) def build_components_xml(root, data): - component_ids = [] - if "component_id" in data: - component_ids.append(data["component_id"]) - + component_el = ET.SubElement( + root, + "Component", + Id=data["component_id"], + Guid=data["component_guid"], + Directory=data["directory_id"], + ) + for filename in data["files"]: + ET.SubElement(component_el, "File", Source=filename) for subdata in data["dirs"]: - if "component_guid" in subdata: - dir_ref_el = ET.SubElement(root, "DirectoryRef", Id=subdata["id"]) - component_el = ET.SubElement( - dir_ref_el, - "Component", - Id=subdata["component_id"], - Guid=subdata["component_guid"], - ) - for filename in subdata["files"]: - file_el = ET.SubElement( - component_el, "File", Source=filename, Id="file_" + uuid.uuid4().hex - ) - - component_ids += build_components_xml(root, subdata) - - return component_ids + build_components_xml(root, subdata) def main(): @@ -125,120 +86,188 @@ def main(): # -rc markers. version = f.read().strip().split("-")[0] - dist_dir = os.path.join( + build_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "build", - "exe.win-amd64-3.12", ) + + cx_freeze_dir = "exe.win-amd64-3.12" + + dist_dir = os.path.join(build_dir, cx_freeze_dir) + if not os.path.exists(dist_dir): print("You must build the dangerzone binary before running this") return - data = { - "id": "TARGETDIR", - "name": "SourceDir", - "dirs": [ - { - "id": "ProgramFilesFolder", - "dirs": [], - }, - { - "id": "ProgramMenuFolder", - "dirs": [], - }, - ], - } + # Prepare data for WiX file harvesting from the output of cx_Freeze + data = build_data( + dist_dir, + cx_freeze_dir, + "INSTALLFOLDER", + "Dangerzone", + ) - data["dirs"][0]["dirs"].append( - build_data( - dist_dir, - "exe.win-amd64-3.12", - "INSTALLDIR", - "Dangerzone", - ) + # Add the Wix root element + wix_el = ET.Element( + "Wix", + { + "xmlns": "http://wixtoolset.org/schemas/v4/wxs", + "xmlns:ui": "http://wixtoolset.org/schemas/v4/wxs/ui", + }, ) - root_el = ET.Element("Wix", xmlns="http://schemas.microsoft.com/wix/2006/wi") - product_el = ET.SubElement( - root_el, - "Product", + # Add the Package element + package_el = ET.SubElement( + wix_el, + "Package", Name="Dangerzone", Manufacturer="Freedom of the Press Foundation", - Id="*", - UpgradeCode="$(var.ProductUpgradeCode)", + UpgradeCode="12B9695C-965B-4BE0-BC33-21274E809576", Language="1033", + Compressed="yes", Codepage="1252", - Version="$(var.ProductVersion)", + Version=version, ) ET.SubElement( - product_el, - "Package", - Id="*", + package_el, + "SummaryInformation", Keywords="Installer", - Description="Dangerzone $(var.ProductVersion) Installer", - Manufacturer="Freedom of the Press Foundation", - InstallerVersion="100", - Languages="1033", - Compressed="yes", - SummaryCodepage="1252", + Description="Dangerzone " + version + " Installer", + Codepage="1252", ) - ET.SubElement(product_el, "Media", Id="1", Cabinet="product.cab", EmbedCab="yes") + ET.SubElement(package_el, "MediaTemplate", EmbedCab="yes") ET.SubElement( - product_el, "Icon", Id="ProductIcon", SourceFile="..\\share\\dangerzone.ico" + package_el, "Icon", Id="ProductIcon", SourceFile="..\\share\\dangerzone.ico" ) - ET.SubElement(product_el, "Property", Id="ARPPRODUCTICON", Value="ProductIcon") + ET.SubElement(package_el, "Property", Id="ARPPRODUCTICON", Value="ProductIcon") ET.SubElement( - product_el, + package_el, "Property", Id="ARPHELPLINK", Value="https://dangerzone.rocks", ) ET.SubElement( - product_el, + package_el, "Property", Id="ARPURLINFOABOUT", Value="https://freedom.press", ) ET.SubElement( - product_el, - "Property", - Id="WIXUI_INSTALLDIR", - Value="INSTALLDIR", + package_el, "ui:WixUI", Id="WixUI_InstallDir", InstallDirectory="INSTALLFOLDER" ) - ET.SubElement(product_el, "UIRef", Id="WixUI_InstallDir") - ET.SubElement(product_el, "UIRef", Id="WixUI_ErrorProgressText") + ET.SubElement(package_el, "UIRef", Id="WixUI_ErrorProgressText") ET.SubElement( - product_el, + package_el, "WixVariable", Id="WixUILicenseRtf", Value="..\\install\\windows\\license.rtf", ) ET.SubElement( - product_el, + package_el, "WixVariable", Id="WixUIDialogBmp", Value="..\\install\\windows\\dialog.bmp", ) ET.SubElement( - product_el, + package_el, "MajorUpgrade", - AllowSameVersionUpgrades="yes", DowngradeErrorMessage="A newer version of [ProductName] is already installed. If you are sure you want to downgrade, remove the existing installation via Programs and Features.", ) - build_dir_xml(product_el, data) - component_ids = build_components_xml(product_el, data) + # Workaround for an issue after upgrading from WiX Toolset v3 to v5 where the previous + # version of Dangerzone is not uninstalled during the upgrade by checking if the older installation + # exists in "C:\Program Files (x86)\Dangerzone". + # + # Also handle a special case for Dangerzone 0.8.0 which allows choosing the install location + # during install by checking if the registry key for it exists. + # + # Note that this seems to allow installing Dangerzone 0.8.0 after installing Dangerzone from this branch. + # In this case the installer errors until Dangerzone 0.8.0 is uninstalled again + # + # TODO: Revert this once we are reasonably certain there aren't too many affected Dangerzone installations. + find_old_el = ET.SubElement(package_el, "Property", Id="OLDDANGERZONEFOUND") + directory_search_el = ET.SubElement( + find_old_el, + "DirectorySearch", + Id="dangerzone_install_folder", + Path="C:\\Program Files (x86)\\Dangerzone", + ) + ET.SubElement(directory_search_el, "FileSearch", Name="dangerzone.exe") + registry_search_el = ET.SubElement(package_el, "Property", Id="DANGERZONE080FOUND") + ET.SubElement( + registry_search_el, + "RegistrySearch", + Root="HKLM", + Key="SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{03C2D2B2-9955-4AED-831F-DA4E67FC0FDB}", + Name="DisplayName", + Type="raw", + ) + ET.SubElement( + package_el, + "Launch", + Condition="NOT OLDDANGERZONEFOUND AND NOT DANGERZONE080FOUND", + Message="A previous version of [ProductName] is already installed. Please uninstall it from Programs and Features before proceeding with the installation.", + ) - feature_el = ET.SubElement(product_el, "Feature", Id="DefaultFeature", Level="1") - for component_id in component_ids: - ET.SubElement(feature_el, "ComponentRef", Id=component_id) + # Add the ProgramMenuFolder StandardDirectory + programmenufolder_el = ET.SubElement( + package_el, + "StandardDirectory", + Id="ProgramMenuFolder", + ) + # Add a shortcut for Dangerzone in the Start menu + shortcut_el = ET.SubElement( + programmenufolder_el, + "Component", + Id="ApplicationShortcuts", + Guid="539E7DE8-A124-4C09-AA55-0DD516AAD7BC", + ) + ET.SubElement( + shortcut_el, + "Shortcut", + Id="DangerzoneStartMenuShortcut", + Name="Dangerzone", + Description="Dangerzone", + Target="[INSTALLFOLDER]dangerzone.exe", + WorkingDirectory="INSTALLFOLDER", + ) + ET.SubElement( + shortcut_el, + "RegistryValue", + Root="HKCU", + Key="Software\\Freedom of the Press Foundation\\Dangerzone", + Name="installed", + Type="integer", + Value="1", + KeyPath="yes", + ) + + # Add the ProgramFilesFolder StandardDirectory + programfilesfolder_el = ET.SubElement( + package_el, + "StandardDirectory", + Id="ProgramFiles64Folder", + ) + + # Create the directory structure for the installed product + build_directory_xml(programfilesfolder_el, data) + + # Create a component group for application components + applicationcomponents_el = ET.SubElement( + package_el, "ComponentGroup", Id="ApplicationComponents" + ) + # Populate the application components group with components for the installed package + build_components_xml(applicationcomponents_el, data) + + # Add the Feature element + feature_el = ET.SubElement(package_el, "Feature", Id="DefaultFeature", Level="1") + ET.SubElement(feature_el, "ComponentGroupRef", Id="ApplicationComponents") ET.SubElement(feature_el, "ComponentRef", Id="ApplicationShortcuts") - print('') - print(f'') - print('') - ET.indent(root_el) - print(ET.tostring(root_el).decode()) + ET.indent(wix_el, space=" ") + + with open(os.path.join(build_dir, "Dangerzone.wxs"), "w") as wxs_file: + wxs_file.write(ET.tostring(wix_el).decode()) if __name__ == "__main__":