diff --git a/.github/workflows/windows-msi-build.yml b/.github/workflows/windows-msi-build.yml
new file mode 100644
index 00000000..07ece13c
--- /dev/null
+++ b/.github/workflows/windows-msi-build.yml
@@ -0,0 +1,428 @@
+name: Windows MSI/MSIX Build
+
+on:
+ push:
+ branches:
+ - master
+ - 'release/**'
+ pull_request:
+ branches:
+ - master
+ workflow_dispatch:
+
+jobs:
+ windows_msi_build:
+ runs-on: windows-2022
+
+ permissions:
+ contents: read
+ id-token: write
+ attestations: write
+ artifact-metadata: write
+
+ strategy:
+ matrix:
+ platform: [x64]
+ arch: [x64]
+ fail-fast: false
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.14.3'
+ architecture: ${{ matrix.arch }}
+
+ - name: Install Dependencies
+ shell: pwsh
+ run: |
+ New-Item -Name "deps" -ItemType "Directory" -Force | Out-Null
+
+ python -m pip install --upgrade pip
+ python -m pip install cffi
+ python -m pip install zstandard
+
+ $ProgressPreference = 'SilentlyContinue'
+ function Download-WithRetry {
+ param(
+ [string]$Url,
+ [string]$OutFile,
+ [int]$MaxAttempts = 5,
+ [int]$InitialDelaySeconds = 2
+ )
+ for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
+ try {
+ Invoke-WebRequest -Uri $Url -OutFile $OutFile -ErrorAction Stop
+ return
+ }
+ catch {
+ if ($attempt -eq $MaxAttempts) {
+ throw
+ }
+ Start-Sleep -Seconds ($InitialDelaySeconds * [math]::Pow(2, $attempt - 1))
+ }
+ }
+ }
+
+ # The existing solution includes installer.vcxproj, so Inno Setup is
+ # still required to build the unmodified solution. This workflow does
+ # not publish that EXE; it packages the same staged rel tree as MSI/MSIX.
+ Download-WithRetry -Url https://github.com/jrsoftware/issrc/releases/download/is-6_7_1/innosetup-6.7.1.exe -OutFile deps\innosetup-unicode.exe
+ & deps\innosetup-unicode.exe /VERYSILENT | Out-Null
+
+ # Use the same GTK runtime as the existing Windows EXE workflow.
+ Download-WithRetry -Url https://github.com/ZoiteChat/gvsbuild/releases/download/zoitechat-2.20.0-alpha.1/GTK3_Gvsbuild_zoitechat-2.20.0-alpha.1_x64.zip -OutFile deps\gtk-${{ matrix.arch }}.zip
+ Expand-Archive -LiteralPath deps\gtk-${{ matrix.arch }}.zip -DestinationPath C:\gtk-build\gtk\x64\release -Force
+
+ # The GTK3 bundle does not contain librsvg. Restore the renderer,
+ # pixbuf loader, and license from the matching GTK4 bundle. Do not
+ # replace the rest of the GTK3 runtime with GTK4 libraries.
+ Download-WithRetry -Url https://github.com/ZoiteChat/gvsbuild/releases/download/zoitechat-2.20.0-alpha.1/GTK4_Gvsbuild_zoitechat-2.20.0-alpha.1_x64.zip -OutFile deps\svg-runtime.zip
+ if ((Get-FileHash deps\svg-runtime.zip -Algorithm SHA256).Hash -ne 'fb657f9ebae3a6807defb15e66091d643a703ee66cb37488e3e11e5646f45adb') {
+ throw 'Unexpected SVG runtime archive checksum'
+ }
+ $svgArchive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path deps\svg-runtime.zip))
+ try {
+ foreach ($name in @('bin/rsvg-2-2.dll', 'lib/gdk-pixbuf-2.0/2.10.0/loaders/pixbufloader_svg.dll', 'share/doc/librsvg/COPYING.LIB')) {
+ $entry = $svgArchive.GetEntry($name)
+ if ($null -eq $entry) { throw "Missing SVG runtime file: $name" }
+ $destination = Join-Path 'C:\gtk-build\gtk\x64\release' $name
+ New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null
+ [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destination, $true)
+ }
+ }
+ finally { $svgArchive.Dispose() }
+
+ # Ensure SVG remains loadable before compiling or packaging.
+ & C:\gtk-build\gtk\x64\release\bin\gdk-pixbuf-query-loaders.exe --update-cache
+ if ($LASTEXITCODE -ne 0) { throw 'Could not generate the pixbuf loader cache' }
+ if (-not (Select-String -LiteralPath 'C:\gtk-build\gtk\x64\release\lib\gdk-pixbuf-2.0\2.10.0\loaders.cache' -SimpleMatch '"svg"')) {
+ throw 'SVG loader could not be loaded with the GTK3 runtime'
+ }
+
+ Download-WithRetry -Url https://repo.msys2.org/mingw/x86_64/mingw-w64-x86_64-hicolor-icon-theme-0.18-1-any.pkg.tar.zst -OutFile deps\hicolor-icon-theme.pkg.tar.zst
+ python -c "import tarfile,zstandard,pathlib;archive=pathlib.Path(r'deps\\hicolor-icon-theme.pkg.tar.zst');target=pathlib.Path(r'C:\\gtk-build\\gtk\\x64\\release');dctx=zstandard.ZstdDecompressor();f=archive.open('rb');reader=dctx.stream_reader(f);tf=tarfile.open(fileobj=reader,mode='r|');[tf.extract(m,path=target) for m in tf if m.name.startswith('mingw64/share/icons/hicolor/')];tf.close();reader.close();f.close()"
+
+ Download-WithRetry -Url https://repo.msys2.org/mingw/x86_64/mingw-w64-x86_64-libarchive-3.8.6-1-any.pkg.tar.zst -OutFile deps\libarchive.pkg.tar.zst
+ python -c "import tarfile,zstandard,pathlib;archive=pathlib.Path(r'deps\\libarchive.pkg.tar.zst');target=pathlib.Path(r'C:\\gtk-build\\gtk\\x64\\release');dctx=zstandard.ZstdDecompressor();f=archive.open('rb');reader=dctx.stream_reader(f);tf=tarfile.open(fileobj=reader,mode='r|');[tf.extract(m,path=target) for m in tf if m.name.startswith(('mingw64/include/archive','mingw64/lib/libarchive','mingw64/bin/libarchive'))];tf.close();reader.close();f.close()"
+
+ if (Test-Path C:\gtk-build\gtk\x64\release\mingw64\share\icons\hicolor) {
+ New-Item -Path C:\gtk-build\gtk\x64\release\share\icons -ItemType Directory -Force | Out-Null
+ Copy-Item -Path C:\gtk-build\gtk\x64\release\mingw64\share\icons\hicolor -Destination C:\gtk-build\gtk\x64\release\share\icons\hicolor -Recurse -Force
+ }
+ if (Test-Path C:\gtk-build\gtk\x64\release\mingw64\include) {
+ New-Item -Path C:\gtk-build\gtk\x64\release\include -ItemType Directory -Force | Out-Null
+ Copy-Item -Path C:\gtk-build\gtk\x64\release\mingw64\include\archive* -Destination C:\gtk-build\gtk\x64\release\include -Recurse -Force
+ }
+ if (Test-Path C:\gtk-build\gtk\x64\release\mingw64\lib) {
+ New-Item -Path C:\gtk-build\gtk\x64\release\lib -ItemType Directory -Force | Out-Null
+ Copy-Item -Path C:\gtk-build\gtk\x64\release\mingw64\lib\libarchive* -Destination C:\gtk-build\gtk\x64\release\lib -Force
+ }
+ if (Test-Path C:\gtk-build\gtk\x64\release\mingw64\bin) {
+ New-Item -Path C:\gtk-build\gtk\x64\release\bin -ItemType Directory -Force | Out-Null
+ Copy-Item -Path C:\gtk-build\gtk\x64\release\mingw64\bin\libarchive*.dll -Destination C:\gtk-build\gtk\x64\release\bin -Force
+ }
+ if (Test-Path C:\gtk-build\gtk\x64\release\mingw64) {
+ Remove-Item -Path C:\gtk-build\gtk\x64\release\mingw64 -Recurse -Force
+ }
+
+ Download-WithRetry -Url https://github.com/ZoiteChat/gvsbuild/releases/download/zoitechat-2.18.0-pre4/gendef20260315.7z -OutFile deps\gendef.7z
+ & 7z.exe x deps\gendef.7z -oC:\gtk-build
+
+ Download-WithRetry -Url https://github.com/ZoiteChat/gvsbuild/releases/download/zoitechat-2.18.0-pre4/WinSparkle-20260315.7z -OutFile deps\WinSparkle.7z
+ & 7z.exe x deps\WinSparkle.7z -oC:\gtk-build\WinSparkle
+
+ Download-WithRetry -Url https://github.com/ZoiteChat/gvsbuild/releases/download/zoitechat-2.18.0-pre4/perl-5.42.0.1-${{ matrix.arch }}.7z -OutFile deps\perl-${{ matrix.arch }}.7z
+ & 7z.exe x deps\perl-${{ matrix.arch }}.7z -oC:\gtk-build\perl-5.42.0.1\${{ matrix.platform }}
+
+ $pyRoot = $env:pythonLocation
+ if (-not $pyRoot) { $pyRoot = & python -c "import sys; print(sys.prefix)" }
+
+ foreach ($pyDir in @("C:\gtk-build\python-3.14.3", "C:\gtk-build\python-3.14")) {
+ New-Item -Path $pyDir -ItemType Directory -Force | Out-Null
+ $target = Join-Path $pyDir "${{ matrix.platform }}"
+ if (Test-Path $target) { Remove-Item $target -Recurse -Force }
+ New-Item -Path $pyDir -Name "${{ matrix.platform }}" -ItemType Junction -Value $pyRoot | Out-Null
+ }
+
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.8.10'
+ architecture: ${{ matrix.arch }}
+
+ - name: Prepare Python 3.8
+ shell: pwsh
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install cffi
+
+ $pyRoot = $env:pythonLocation
+ if (-not $pyRoot) { $pyRoot = & python -c "import sys; print(sys.prefix)" }
+
+ foreach ($pyDir in @("C:\gtk-build\python-3.8.10", "C:\gtk-build\python-3.8")) {
+ New-Item -Path $pyDir -ItemType Directory -Force | Out-Null
+ $target = Join-Path $pyDir "${{ matrix.platform }}"
+ if (Test-Path $target) { Remove-Item $target -Recurse -Force }
+ New-Item -Path $pyDir -Name "${{ matrix.platform }}" -ItemType Junction -Value $pyRoot | Out-Null
+ }
+
+ - name: Build
+ shell: cmd
+ run: |
+ call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat"
+
+ set "PYTHON_DIR=C:\gtk-build\python-3.14.3\${{ matrix.platform }}"
+ if not exist "%PYTHON_DIR%\libs\python314.lib" (
+ echo Missing %PYTHON_DIR%\libs\python314.lib
+ dir "%PYTHON_DIR%\libs"
+ exit /b 1
+ )
+
+ set "PYTHON38_DIR=C:\gtk-build\python-3.8\${{ matrix.platform }}"
+ if not exist "%PYTHON38_DIR%\libs\python38.lib" (
+ echo Missing %PYTHON38_DIR%\libs\python38.lib
+ dir "%PYTHON38_DIR%\libs"
+ exit /b 1
+ )
+ if not exist "%PYTHON38_DIR%\Lib\site-packages\_cffi_backend.cp38*.pyd" (
+ echo Missing %PYTHON38_DIR%\Lib\site-packages\_cffi_backend.cp38*.pyd
+ exit /b 1
+ )
+
+ set "LIB=%PYTHON_DIR%\libs;%LIB%"
+ set "INCLUDE=%PYTHON_DIR%\include;%INCLUDE%"
+
+ powershell -NoProfile -ExecutionPolicy Bypass -Command "$archiveLib='C:\gtk-build\gtk\x64\release\lib\libarchive.lib'; if (-not (Test-Path $archiveLib)) { $archiveDll = Get-ChildItem 'C:\gtk-build\gtk\x64\release\bin\libarchive*.dll' | Select-Object -First 1; if ($archiveDll) { Push-Location 'C:\gtk-build\gtk\x64\release\lib'; & 'C:\gtk-build\gendef\gendef.exe' $archiveDll.FullName | Out-Null; $archiveDef = Get-ChildItem 'libarchive*.def' | Select-Object -First 1; if ($archiveDef) { & lib /def:$archiveDef.Name /machine:${{ matrix.platform }} /out:libarchive.lib | Out-Null }; Pop-Location } }"
+
+ msbuild win32\zoitechat.sln /m /verbosity:minimal /p:Configuration=Release /p:Platform=${{ matrix.platform }}
+ if errorlevel 1 exit /b 1
+
+ if not exist "..\zoitechat-build\${{ matrix.platform }}\rel\plugins\hcpython38.dll" (
+ echo hcpython38.dll was not built
+ exit /b 1
+ )
+ if not exist "..\zoitechat-build\${{ matrix.platform }}\rel\_cffi_backend.cp38*.pyd" (
+ echo _cffi_backend for Python 3.8 was not staged for the installer
+ exit /b 1
+ )
+
+ # Master added native emoji font staging and tests after the release
+ # branch split. Run these before packaging so MSI/MSIX use verified files.
+ - name: Test emoji catalog, font coverage and picker
+ shell: cmd
+ run: |
+ call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat"
+ set "PATH=%CD%\..\zoitechat-build\x64\rel;%PATH%"
+ set "GDK_PIXBUF_MODULE_FILE=%CD%\..\zoitechat-build\x64\rel\lib\gdk-pixbuf-2.0\2.10.0\loaders.cache"
+ set "ZOITECHAT_EMOJI_FONT=%CD%\..\zoitechat-build\x64\rel\share\fonts\zoitechat\NotoColorEmoji.ttf"
+ fc /b data\fonts\NotoColorEmoji.ttf "%ZOITECHAT_EMOJI_FONT%"
+ if errorlevel 1 exit /b 1
+ for %%T in (data font ui) do (
+ msbuild win32\emoji-tests.vcxproj /m /verbosity:minimal /p:Configuration=Release /p:Platform=x64 /p:EmojiTestKind=%%T
+ if errorlevel 1 exit /b 1
+ ..\zoitechat-build\x64\tests\emoji-%%T-tests.exe --tap
+ if errorlevel 1 exit /b 1
+ )
+
+ - name: Install WiX Toolset
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ # Keep this pinned. WiX v6 introduced OSMF terms and WiX v7 adds
+ # explicit EULA acceptance; do not silently accept new terms in CI.
+ dotnet tool install --tool-path .wix wix --version 5.0.2
+ if ($LASTEXITCODE -ne 0) { throw 'Failed to install WiX Toolset 5.0.2' }
+ & .\.wix\wix.exe --version
+ if ($LASTEXITCODE -ne 0) { throw 'WiX Toolset could not be executed' }
+
+ - name: Build MSI
+ id: build_msi
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $rawVersion = (Get-Content VERSION -Raw).Trim()
+ if ($rawVersion -notmatch '^(\d+)\.(\d+)\.(\d+)') {
+ throw "VERSION '$rawVersion' does not begin with an MSI-compatible major.minor.patch version"
+ }
+
+ # Keep the full development version in the filename, while MSI's
+ # ProductVersion is numeric (e.g. 2.20.0-dev -> 2.20.0).
+ $msiVersion = "$($Matches[1]).$($Matches[2]).$($Matches[3])"
+ $safeVersion = $rawVersion -replace '[^0-9A-Za-z._-]', '-'
+ $sourceDir = (Resolve-Path "..\zoitechat-build\${{ matrix.platform }}\rel").Path
+ $outputPath = Join-Path $PWD "ZoiteChat-${safeVersion}_${{ matrix.arch }}.msi"
+
+ if (-not (Test-Path (Join-Path $sourceDir 'zoitechat.exe'))) {
+ throw "Staged ZoiteChat executable is missing from $sourceDir"
+ }
+
+ & .\.wix\wix.exe build win32\wix\zoitechat.wxs `
+ -arch x64 `
+ -d "SourceDir=$sourceDir" `
+ -d "ProductVersion=$msiVersion" `
+ -out $outputPath
+ if ($LASTEXITCODE -ne 0) { throw 'WiX MSI build failed' }
+
+ if (-not (Test-Path $outputPath)) {
+ throw "Expected MSI was not created: $outputPath"
+ }
+
+ "path=$outputPath" >> $env:GITHUB_OUTPUT
+ "filename=$(Split-Path $outputPath -Leaf)" >> $env:GITHUB_OUTPUT
+
+ - name: Test MSI administrative extraction
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $msi = '${{ steps.build_msi.outputs.path }}'
+ $adminImage = Join-Path $PWD 'msi-admin-image'
+ New-Item -ItemType Directory -Path $adminImage -Force | Out-Null
+
+ $arguments = "/a `"$msi`" /qn TARGETDIR=`"$adminImage`""
+ $process = Start-Process -FilePath msiexec.exe -ArgumentList $arguments -Wait -PassThru
+ if ($process.ExitCode -ne 0) {
+ throw "MSI administrative extraction failed with exit code $($process.ExitCode)"
+ }
+
+ foreach ($requiredFile in @(
+ 'zoitechat.exe',
+ 'hclua.dll',
+ 'hcupd.dll',
+ 'WinSparkle.dll',
+ 'hcchecksum.dll',
+ 'hcexec.dll',
+ 'hcfishlim.dll',
+ 'hcsysinfo.dll',
+ 'system.png',
+ 'hcperl.dll',
+ 'hcpython3.dll',
+ 'hcpython38.dll',
+ 'NotoColorEmoji.ttf',
+ 'OFL.txt'
+ )) {
+ $match = Get-ChildItem -Path $adminImage -Filter $requiredFile -File -Recurse | Select-Object -First 1
+ if ($null -eq $match) {
+ throw "Administrative image does not contain required normal-install file: $requiredFile"
+ }
+ }
+
+ foreach ($requiredPattern in @('_cffi_backend.cp314*.pyd', '_cffi_backend.cp38*.pyd')) {
+ $match = Get-ChildItem -Path $adminImage -Filter $requiredPattern -File -Recurse | Select-Object -First 1
+ if ($null -eq $match) {
+ throw "Administrative image does not contain required Python support file: $requiredPattern"
+ }
+ }
+
+ foreach ($requiredPythonFile in @('zoitechat.py', '_zoitechat.py', 'hexchat.py', 'xchat.py')) {
+ $match = Get-ChildItem -Path $adminImage -Filter $requiredPythonFile -File -Recurse |
+ Where-Object { $_.Directory.Name -eq 'python' } |
+ Select-Object -First 1
+ if ($null -eq $match) {
+ throw "Administrative image does not contain required Python bridge file: $requiredPythonFile"
+ }
+ }
+
+ foreach ($excludedFile in @(
+ 'portable-mode',
+ 'zoitechat-text.exe'
+ )) {
+ $match = Get-ChildItem -Path $adminImage -Filter $excludedFile -File -Recurse | Select-Object -First 1
+ if ($null -ne $match) {
+ throw "MSI incorrectly contains Inno custom/portable-only file: $excludedFile"
+ }
+ }
+
+ - name: Build MSIX
+ id: build_msix
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $rawVersion = (Get-Content VERSION -Raw).Trim()
+ if ($rawVersion -notmatch '^(\d+)\.(\d+)\.(\d+)') {
+ throw "VERSION '$rawVersion' does not begin with a Store-compatible major.minor.build version"
+ }
+
+ $versionParts = @(
+ [int]$Matches[1],
+ [int]$Matches[2],
+ [int]$Matches[3]
+ )
+ if ($versionParts[0] -eq 0) {
+ throw 'The Microsoft Store requires a non-zero major package version.'
+ }
+ foreach ($part in $versionParts) {
+ if ($part -lt 0 -or $part -gt 65535) {
+ throw "MSIX version component '$part' is outside the Store-supported 0-65535 range."
+ }
+ }
+
+ # Partner Center reserves the fourth field as 0. A -dev version
+ # is packaged as a valid 2.20.0.0, without changing VERSION.
+ $packageVersion = "$($versionParts[0]).$($versionParts[1]).$($versionParts[2]).0"
+ $safeVersion = $rawVersion -replace '[^0-9A-Za-z._-]', '-'
+ $sourceDir = (Resolve-Path "..\zoitechat-build\${{ matrix.platform }}\rel").Path
+ $outputPath = Join-Path $PWD "ZoiteChat-${safeVersion}_${{ matrix.arch }}.msix"
+
+ # Reserved ZoiteChat identity from the release branch. Adjust only
+ # if the Product identity page in Partner Center changes.
+ $identityName = 'deependtilde.ZoiteChat'
+ $publisher = 'CN=6C7766BB-6AC6-488B-A09E-E3F8E68E63E4'
+ $publisherDisplayName = 'deepend_tilde'
+ $artifactName = 'MSIX Store Package ${{ matrix.arch }}'
+
+ Write-Host "Building Store MSIX for identity: $identityName"
+
+ & .\win32\msix\build-msix.ps1 `
+ -SourceDir $sourceDir `
+ -OutputPath $outputPath `
+ -PackageVersion $packageVersion `
+ -IdentityName $identityName `
+ -Publisher $publisher `
+ -PublisherDisplayName $publisherDisplayName `
+ -SelfSign
+ if ($LASTEXITCODE -ne 0) {
+ throw 'MSIX build script failed'
+ }
+
+ if (-not (Test-Path $outputPath)) {
+ throw "Expected MSIX was not created: $outputPath"
+ }
+
+ "path=$outputPath" >> $env:GITHUB_OUTPUT
+ "filename=$(Split-Path $outputPath -Leaf)" >> $env:GITHUB_OUTPUT
+ "package_version=$packageVersion" >> $env:GITHUB_OUTPUT
+ "store_ready=true" >> $env:GITHUB_OUTPUT
+ "artifact_name=$artifactName" >> $env:GITHUB_OUTPUT
+
+ - name: Upload MSI Installer
+ id: upload_msi
+ uses: actions/upload-artifact@v6
+ with:
+ name: MSI Installer ${{ matrix.arch }}
+ path: ${{ steps.build_msi.outputs.path }}
+ if-no-files-found: error
+
+ - name: Attest MSI Installer (Artifact Attestation)
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ uses: actions/attest-build-provenance@v3
+ with:
+ subject-name: MSI Installer ${{ matrix.arch }}
+ subject-digest: sha256:${{ steps.upload_msi.outputs.artifact-digest }}
+
+ - name: Upload MSIX Package
+ id: upload_msix
+ uses: actions/upload-artifact@v6
+ with:
+ name: ${{ steps.build_msix.outputs.artifact_name }}
+ path: ${{ steps.build_msix.outputs.path }}
+ if-no-files-found: error
+
+ - name: Attest MSIX Package (Artifact Attestation)
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ uses: actions/attest-build-provenance@v3
+ with:
+ subject-name: ${{ steps.build_msix.outputs.artifact_name }}
+ subject-digest: sha256:${{ steps.upload_msix.outputs.artifact-digest }}
diff --git a/win32/msix/AppxManifest.xml.in b/win32/msix/AppxManifest.xml.in
new file mode 100644
index 00000000..b3920c80
--- /dev/null
+++ b/win32/msix/AppxManifest.xml.in
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+ ZoiteChat
+ @PUBLISHER_DISPLAY_NAME@
+ ZoiteChat IRC client
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ZoiteChat Theme
+
+ .zct
+ .hct
+
+
+
+
+
+
+
diff --git a/win32/msix/build-msix.ps1 b/win32/msix/build-msix.ps1
new file mode 100644
index 00000000..917a268b
--- /dev/null
+++ b/win32/msix/build-msix.ps1
@@ -0,0 +1,392 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $SourceDir,
+
+ [Parameter(Mandatory = $true)]
+ [string] $OutputPath,
+
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern('^\d+\.\d+\.\d+\.0$')]
+ [string] $PackageVersion,
+
+ [Parameter(Mandatory = $true)]
+ [string] $IdentityName,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Publisher,
+
+ [Parameter(Mandatory = $true)]
+ [string] $PublisherDisplayName,
+
+ [switch] $SelfSign
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+Add-Type -AssemblyName System.Drawing
+
+function Find-WindowsSdkTool {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $ToolName
+ )
+
+ $command = Get-Command $ToolName -ErrorAction SilentlyContinue
+ if ($command) {
+ return $command.Source
+ }
+
+ $kitsRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
+ if (-not (Test-Path $kitsRoot)) {
+ throw "Windows SDK bin directory was not found: $kitsRoot"
+ }
+
+ $tool = Get-ChildItem -Path $kitsRoot -Filter $ToolName -File -Recurse |
+ Where-Object { $_.FullName -match '\\x64\\' } |
+ Sort-Object {
+ $versionDirectory = Split-Path (Split-Path $_.DirectoryName -Parent) -Leaf
+ try {
+ [version] $versionDirectory
+ }
+ catch {
+ [version] '0.0'
+ }
+ } -Descending |
+ Select-Object -First 1
+
+ if (-not $tool) {
+ throw "$ToolName was not found in the installed Windows SDK."
+ }
+
+ return $tool.FullName
+}
+
+function Escape-Xml {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Value
+ )
+
+ return [System.Security.SecurityElement]::Escape($Value)
+}
+
+function New-MsixAsset {
+ param(
+ [Parameter(Mandatory = $true)]
+ [System.Drawing.Image] $SourceImage,
+
+ [Parameter(Mandatory = $true)]
+ [int] $Width,
+
+ [Parameter(Mandatory = $true)]
+ [int] $Height,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Path
+ )
+
+ $bitmap = [System.Drawing.Bitmap]::new($Width, $Height)
+ try {
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
+ try {
+ $graphics.Clear([System.Drawing.Color]::Transparent)
+ $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
+ $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
+ $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
+
+ $scale = [Math]::Min($Width / $SourceImage.Width, $Height / $SourceImage.Height)
+ $drawWidth = [Math]::Max(1, [int][Math]::Round($SourceImage.Width * $scale))
+ $drawHeight = [Math]::Max(1, [int][Math]::Round($SourceImage.Height * $scale))
+ $x = [int](($Width - $drawWidth) / 2)
+ $y = [int](($Height - $drawHeight) / 2)
+
+ $graphics.DrawImage($SourceImage, $x, $y, $drawWidth, $drawHeight)
+ }
+ finally {
+ $graphics.Dispose()
+ }
+
+ $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
+ }
+ finally {
+ $bitmap.Dispose()
+ }
+}
+
+$source = (Resolve-Path $SourceDir).Path
+if (-not (Test-Path (Join-Path $source 'zoitechat.exe'))) {
+ throw "Staged ZoiteChat executable is missing from $source"
+}
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
+$templatePath = Join-Path $PSScriptRoot 'AppxManifest.xml.in'
+$iconPath = Join-Path $repoRoot 'data\icons\zoitechat.png'
+
+if (-not (Test-Path $templatePath)) {
+ throw "MSIX manifest template is missing: $templatePath"
+}
+if (-not (Test-Path $iconPath)) {
+ throw "ZoiteChat PNG icon is missing: $iconPath"
+}
+
+if ([System.IO.Path]::IsPathRooted($OutputPath)) {
+ $output = [System.IO.Path]::GetFullPath($OutputPath)
+}
+else {
+ $output = [System.IO.Path]::GetFullPath((Join-Path $PWD $OutputPath))
+}
+$outputDirectory = Split-Path $output -Parent
+New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
+
+$stagingRoot = Join-Path $env:RUNNER_TEMP 'zoitechat-msix'
+$verifyRoot = Join-Path $env:RUNNER_TEMP 'zoitechat-msix-verify'
+$pfxPath = Join-Path $env:RUNNER_TEMP 'zoitechat-msix-dev.pfx'
+
+foreach ($path in @($stagingRoot, $verifyRoot)) {
+ if (Test-Path $path) {
+ Remove-Item -Path $path -Recurse -Force
+ }
+}
+if (Test-Path $pfxPath) {
+ Remove-Item -Path $pfxPath -Force
+}
+
+New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null
+$appRoot = Join-Path $stagingRoot 'ZoiteChat'
+New-Item -ItemType Directory -Path $appRoot -Force | Out-Null
+Copy-Item -Path (Join-Path $source '*') -Destination $appRoot -Recurse -Force
+
+# copy.vcxproj stages everything needed by every Inno component. The Store
+# package keeps the useful bundled plugins and scripting bridges while letting
+# the Microsoft Store own application updates. The WinSparkle updater is the
+# only bundled plugin deliberately removed from the Store package.
+$excludeFiles = @(
+ 'portable-mode',
+ 'zoitechat-text.exe',
+ 'plugins\hcupd.dll',
+ 'WinSparkle.dll',
+ 'share\download.png'
+)
+
+foreach ($relativePath in $excludeFiles) {
+ $path = Join-Path $appRoot $relativePath
+ if (Test-Path $path) {
+ Remove-Item -Path $path -Force
+ }
+}
+
+foreach ($relativePath in @('share\doc\WinSparkle')) {
+ $path = Join-Path $appRoot $relativePath
+ if (Test-Path $path) {
+ Remove-Item -Path $path -Recurse -Force
+ }
+}
+
+foreach ($requiredPath in @(
+ 'zoitechat.exe',
+ 'plugins\hcnotifications-winrt.dll',
+ 'plugins\hcchecksum.dll',
+ 'plugins\hcexec.dll',
+ 'plugins\hcfishlim.dll',
+ 'plugins\hcsysinfo.dll',
+ 'share\system.png',
+ 'share\fonts\zoitechat\NotoColorEmoji.ttf',
+ 'share\doc\zoitechat\fonts\OFL.txt',
+ 'plugins\hclua.dll',
+ 'plugins\hcperl.dll',
+ 'plugins\hcpython3.dll',
+ 'plugins\hcpython38.dll'
+)) {
+ if (-not (Test-Path (Join-Path $appRoot $requiredPath))) {
+ throw "MSIX staging is missing required file: $requiredPath"
+ }
+}
+
+foreach ($requiredPattern in @('_cffi_backend.cp314*.pyd', '_cffi_backend.cp38*.pyd')) {
+ $match = Get-ChildItem -Path $appRoot -Filter $requiredPattern -File -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if ($null -eq $match) {
+ throw "MSIX staging is missing required Python support file: $requiredPattern"
+ }
+}
+
+foreach ($requiredPythonFile in @('zoitechat.py', '_zoitechat.py', 'hexchat.py', 'xchat.py')) {
+ $path = Join-Path (Join-Path $appRoot 'python') $requiredPythonFile
+ if (-not (Test-Path $path)) {
+ throw "MSIX staging is missing required Python bridge file: $requiredPythonFile"
+ }
+}
+
+$assets = Join-Path $stagingRoot 'Assets'
+New-Item -ItemType Directory -Path $assets -Force | Out-Null
+
+$image = [System.Drawing.Image]::FromFile($iconPath)
+try {
+ New-MsixAsset -SourceImage $image -Width 50 -Height 50 `
+ -Path (Join-Path $assets 'StoreLogo.png')
+ New-MsixAsset -SourceImage $image -Width 44 -Height 44 `
+ -Path (Join-Path $assets 'Square44x44Logo.png')
+ New-MsixAsset -SourceImage $image -Width 150 -Height 150 `
+ -Path (Join-Path $assets 'Square150x150Logo.png')
+}
+finally {
+ $image.Dispose()
+}
+
+$manifest = Get-Content $templatePath -Raw -Encoding UTF8
+$manifest = $manifest.Replace('@IDENTITY_NAME@', (Escape-Xml $IdentityName))
+$manifest = $manifest.Replace('@PUBLISHER@', (Escape-Xml $Publisher))
+$manifest = $manifest.Replace('@PUBLISHER_DISPLAY_NAME@', (Escape-Xml $PublisherDisplayName))
+$manifest = $manifest.Replace('@PACKAGE_VERSION@', (Escape-Xml $PackageVersion))
+
+if ($manifest -match '@[A-Z0-9_]+@') {
+ throw "Unexpanded MSIX manifest placeholder: $($Matches[0])"
+}
+
+$manifestPath = Join-Path $stagingRoot 'AppxManifest.xml'
+[System.IO.File]::WriteAllText(
+ $manifestPath,
+ $manifest,
+ [System.Text.UTF8Encoding]::new($false)
+)
+
+# Catch malformed XML before MakeAppx so CI reports a useful error.
+[xml](Get-Content $manifestPath -Raw -Encoding UTF8) | Out-Null
+
+$makeAppx = Find-WindowsSdkTool -ToolName 'MakeAppx.exe'
+$signtool = Find-WindowsSdkTool -ToolName 'SignTool.exe'
+
+if (Test-Path $output) {
+ Remove-Item -Path $output -Force
+}
+
+& $makeAppx pack /d $stagingRoot /p $output /o
+if ($LASTEXITCODE -ne 0) {
+ throw "MakeAppx failed with exit code $LASTEXITCODE"
+}
+
+$certificate = $null
+try {
+ if ($SelfSign) {
+ # Partner Center replaces this development signature with Microsoft's
+ # trusted Store signature. The certificate subject must exactly match
+ # the manifest Publisher for SignTool to accept the package.
+ $certificate = New-SelfSignedCertificate `
+ -Type Custom `
+ -KeyUsage DigitalSignature `
+ -Subject $Publisher `
+ -CertStoreLocation 'Cert:\CurrentUser\My' `
+ -HashAlgorithm SHA256 `
+ -TextExtension @(
+ '2.5.29.37={text}1.3.6.1.5.5.7.3.3',
+ '2.5.29.19={text}'
+ ) `
+ -FriendlyName 'ZoiteChat MSIX CI signing certificate'
+
+ if ($certificate.Subject -cne $Publisher) {
+ throw "Generated certificate subject '$($certificate.Subject)' does not exactly match manifest Publisher '$Publisher'."
+ }
+
+ $passwordText = [Guid]::NewGuid().ToString('N')
+ $password = ConvertTo-SecureString -String $passwordText -AsPlainText -Force
+ Export-PfxCertificate -Cert $certificate -FilePath $pfxPath -Password $password | Out-Null
+
+ & $signtool sign /fd SHA256 /f $pfxPath /p $passwordText $output
+ if ($LASTEXITCODE -ne 0) {
+ throw "SignTool failed with exit code $LASTEXITCODE"
+ }
+
+ $signature = Get-AuthenticodeSignature -FilePath $output
+ if (-not $signature.SignerCertificate -or
+ $signature.SignerCertificate.Subject -cne $Publisher) {
+ throw 'MSIX signature does not match the manifest Publisher.'
+ }
+ }
+
+ New-Item -ItemType Directory -Path $verifyRoot -Force | Out-Null
+ & $makeAppx unpack /p $output /d $verifyRoot /o
+ if ($LASTEXITCODE -ne 0) {
+ throw "MakeAppx validation unpack failed with exit code $LASTEXITCODE"
+ }
+
+ [xml] $verifiedManifest = Get-Content (Join-Path $verifyRoot 'AppxManifest.xml') -Raw -Encoding UTF8
+ $ns = New-Object System.Xml.XmlNamespaceManager($verifiedManifest.NameTable)
+ $ns.AddNamespace('f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10')
+ $identity = $verifiedManifest.SelectSingleNode('/f:Package/f:Identity', $ns)
+ if (-not $identity) {
+ throw 'Validated MSIX does not contain a package Identity.'
+ }
+ if ($identity.Name -cne $IdentityName -or
+ $identity.Publisher -cne $Publisher -or
+ $identity.Version -cne $PackageVersion) {
+ throw 'Validated MSIX identity does not match the requested Store identity.'
+ }
+
+ foreach ($requiredPath in @(
+ 'ZoiteChat\zoitechat.exe',
+ 'ZoiteChat\plugins\hcnotifications-winrt.dll',
+ 'ZoiteChat\plugins\hcchecksum.dll',
+ 'ZoiteChat\plugins\hcexec.dll',
+ 'ZoiteChat\plugins\hcfishlim.dll',
+ 'ZoiteChat\plugins\hcsysinfo.dll',
+ 'ZoiteChat\share\system.png',
+ 'ZoiteChat\share\fonts\zoitechat\NotoColorEmoji.ttf',
+ 'ZoiteChat\share\doc\zoitechat\fonts\OFL.txt',
+ 'ZoiteChat\plugins\hclua.dll',
+ 'ZoiteChat\plugins\hcperl.dll',
+ 'ZoiteChat\plugins\hcpython3.dll',
+ 'ZoiteChat\plugins\hcpython38.dll'
+ )) {
+ if (-not (Test-Path (Join-Path $verifyRoot $requiredPath))) {
+ throw "Validated MSIX is missing required file: $requiredPath"
+ }
+ }
+
+ foreach ($requiredPattern in @('_cffi_backend.cp314*.pyd', '_cffi_backend.cp38*.pyd')) {
+ $match = Get-ChildItem -Path (Join-Path $verifyRoot 'ZoiteChat') -Filter $requiredPattern -File -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if ($null -eq $match) {
+ throw "Validated MSIX is missing required Python support file: $requiredPattern"
+ }
+ }
+
+ foreach ($requiredPythonFile in @('zoitechat.py', '_zoitechat.py', 'hexchat.py', 'xchat.py')) {
+ $path = Join-Path (Join-Path $verifyRoot 'ZoiteChat\python') $requiredPythonFile
+ if (-not (Test-Path $path)) {
+ throw "Validated MSIX is missing required Python bridge file: $requiredPythonFile"
+ }
+ }
+
+ foreach ($excludedPath in @(
+ 'ZoiteChat\portable-mode',
+ 'ZoiteChat\zoitechat-text.exe',
+ 'ZoiteChat\plugins\hcupd.dll',
+ 'ZoiteChat\WinSparkle.dll',
+ 'ZoiteChat\share\download.png'
+ )) {
+ if (Test-Path (Join-Path $verifyRoot $excludedPath)) {
+ throw "MSIX incorrectly contains excluded file/directory: $excludedPath"
+ }
+ }
+}
+finally {
+ if ($certificate) {
+ Remove-Item -Path "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -Force -ErrorAction SilentlyContinue
+ }
+ if (Test-Path $pfxPath) {
+ Remove-Item -Path $pfxPath -Force -ErrorAction SilentlyContinue
+ }
+ foreach ($path in @($stagingRoot, $verifyRoot)) {
+ if (Test-Path $path) {
+ Remove-Item -Path $path -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+}
+
+if (-not (Test-Path $output)) {
+ throw "Expected MSIX was not created: $output"
+}
+
+Write-Host "Created MSIX: $output"
diff --git a/win32/wix/zoitechat.wxs b/win32/wix/zoitechat.wxs
new file mode 100644
index 00000000..b120e199
--- /dev/null
+++ b/win32/wix/zoitechat.wxs
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+