Compare commits

..
6 Commits
Author SHA1 Message Date
deepend-tildeclub 3a32b1eae6 Merge pull request #423 from ZoiteChat/macos-build
begin rebuild of macos build
2026-10-17 14:51:54 -06:00
deepend 7bf7e31459 embed the standard scaling rules 2026-10-17 13:08:33 -06:00
deepend c931867c4a visual check 2026-10-17 13:08:33 -06:00
deepend a2c0eb646f rebuild macos build 2026-10-17 13:08:32 -06:00
deepend-tildeclub 74d9e35b37 Merge pull request #422 from ZoiteChat/msi-msix
add msi/msix build support
2026-10-14 18:13:31 -07:00
deepend 9e3daf494f msi/msix support 2026-10-13 19:13:50 -06:00
11 changed files with 1526 additions and 10 deletions

No files matched your search

+14
View File
@@ -64,6 +64,20 @@ jobs:
run: python3 packaging/macos/build.py --build-number '${{ github.run_number }}' run: python3 packaging/macos/build.py --build-number '${{ github.run_number }}'
- name: Verify relocated startup - name: Verify relocated startup
run: python3 packaging/macos/smoke.py dist/ZoiteChat.app run: python3 packaging/macos/smoke.py dist/ZoiteChat.app
# Desktop capture can be unavailable on a runner image. Preserve the
# existing build gate and publish the separate visual-check outcome.
- name: Exercise IRC and capture the macOS application window
continue-on-error: true
timeout-minutes: 5
run: python3 packaging/macos/visual-smoke.py dist/ZoiteChat.app
- name: Upload screenshot and visual-check evidence
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: ZoiteChat-macos-${{ matrix.arch }}-visual-check
path: dist/visual-check/
if-no-files-found: ignore
retention-days: 14
- name: Create artifact preserving the application signature - name: Create artifact preserving the application signature
run: ditto -c -k --keepParent dist/ZoiteChat.app dist/ZoiteChat-${{ matrix.arch }}.zip run: ditto -c -k --keepParent dist/ZoiteChat.app dist/ZoiteChat-${{ matrix.arch }}.zip
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
+428
View File
@@ -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 }}
+36
View File
@@ -59,6 +59,42 @@ The smoke check copies the app to a path containing spaces, verifies its signatu
runs `--version`, and keeps an isolated GUI process alive for ten seconds without runs `--version`, and keeps an isolated GUI process alive for ten seconds without
connecting to IRC. It tests startup, not interaction or long-running stability. connecting to IRC. It tests startup, not interaction or long-running stability.
### Screenshots and a loopback IRC check without owning a Mac
The ordinary ARM and Intel workflow jobs also run a separate visual check.
After pushing this change to a pull request, open the new **macOS build** run
and download `ZoiteChat-macos-arm64-visual-check` and
`ZoiteChat-macos-x86_64-visual-check` from **Artifacts**. Each contains:
- `zoitechat.png`: the actual running application's window on that architecture.
- `result.json`: the checks completed, window dimensions, architecture and commit.
- `irc.log`: the temporary loopback server's registration, channel and PING/PONG traffic.
- `startup.log`: the application's startup diagnostics.
The check copies the built app to a path containing spaces, verifies its
signature, starts it with temporary settings, connects to a server bound only
to `127.0.0.1`, joins `#ci-smoke`, receives sample text and verifies its PONG
response. It captures the largest visible window owned by that app's process
and rejects failed or nearly uniform captures. No public IRC server or account
is used. The original application artifact is not modified.
This is a supplemental check with `continue-on-error`: a green build alone
does **not** mean the visual check passed. Inspect `result.json` for
`"status": "passed"` and view the PNG. If the runner has no desktop or denies
Screen Recording, the step reports failure and publishes diagnostics instead
of claiming a screenshot. No signing secrets are needed. The check runs in
the ordinary build job; it does not alter the release-signing job.
It can also run on a Mac with a logged-in desktop:
```sh
python3 packaging/macos/visual-smoke.py dist/ZoiteChat.app --output dist/visual-check
```
Use a new output directory for each run. This verifies a narrow set of startup,
IRC and rendering behavior; it does not test keyboard/clipboard interaction,
TLS, notifications, Gatekeeper distribution, or long-running stability.
The audit verifies the entire dependency closure, including GTK image/input The audit verifies the entire dependency closure, including GTK image/input
modules, GIO TLS, Enchant providers, OpenSSL providers and ZoiteChat plugins. It modules, GIO TLS, Enchant providers, OpenSSL providers and ZoiteChat plugins. It
fails on missing libraries, external symlinks/search paths, architecture mismatches fails on missing libraries, external symlinks/search paths, architecture mismatches
+26 -10
View File
@@ -12,6 +12,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
MACH_MAGIC = {bytes.fromhex(h) for h in ("feedface", "cefaedfe", "feedfacf", "cffaedfe", "cafebabe", "bebafeca", "cafebabf", "bfbafeca")} MACH_MAGIC = {bytes.fromhex(h) for h in ("feedface", "cefaedfe", "feedfacf", "cffaedfe", "cafebabe", "bebafeca", "cafebabf", "bfbafeca")}
@@ -123,6 +124,29 @@ def copy_tree(source: Path, target: Path) -> None:
shutil.copytree(source, target, dirs_exist_ok=True, symlinks=False) shutil.copytree(source, target, dirs_exist_ok=True, symlinks=False)
def write_fontconfig(resources: Path, fontconfig_prefix: Path) -> None:
# Noto Color Emoji has fixed-size color bitmaps. Fontconfig supplies the
# requested-size/strike-size matrix; without it glyphs render at 109px.
rule = fontconfig_prefix / "share/fontconfig/conf.avail/10-scale-bitmap-fonts.conf"
if not rule.is_file():
raise RuntimeError(f"Required Fontconfig bitmap scaling rules not found: {rule}")
resources.mkdir(parents=True, exist_ok=True)
config = ET.fromstring('''<fontconfig>
<dir>/System/Library/Fonts</dir><dir>/Library/Fonts</dir><dir>~/Library/Fonts</dir>
<cachedir prefix="xdg">fontconfig</cachedir>
<alias><family>sans-serif</family><prefer><family>Helvetica</family></prefer></alias>
<alias><family>monospace</family><prefer><family>Menlo</family></prefer></alias>
</fontconfig>
''')
# Embed the standard rules so they work after relocation without depending
# on Homebrew or Fontconfig's host configuration search paths.
for element in reversed(list(ET.parse(rule).getroot())):
config.insert(0, element)
(resources / "fonts.conf").write_text(
'<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">\n' +
ET.tostring(config, encoding="unicode") + "\n", encoding="utf-8")
def audit(app: Path) -> dict[str, object]: def audit(app: Path) -> dict[str, object]:
binary = app / "Contents/MacOS/zoitechat" binary = app / "Contents/MacOS/zoitechat"
architectures = set(run("lipo", "-archs", binary).split()) architectures = set(run("lipo", "-archs", binary).split())
@@ -180,7 +204,7 @@ def package(stage: Path, app: Path, build_number: str) -> None:
seeds.append((source, resources / "lib/zoitechat/plugins" / source.name)) seeds.append((source, resources / "lib/zoitechat/plugins" / source.name))
prefixes = {formula: Path(run("brew", "--prefix", formula)) for formula in ( prefixes = {formula: Path(run("brew", "--prefix", formula)) for formula in (
"gtk+3", "gdk-pixbuf", "glib", "glib-networking", "librsvg", "enchant", "adwaita-icon-theme", "hicolor-icon-theme", "gsettings-desktop-schemas", "ca-certificates", "openssl@3", "lua", "libarchive", "gtk-mac-integration")} "gtk+3", "gdk-pixbuf", "glib", "glib-networking", "librsvg", "enchant", "adwaita-icon-theme", "hicolor-icon-theme", "gsettings-desktop-schemas", "ca-certificates", "openssl@3", "lua", "libarchive", "gtk-mac-integration", "fontconfig")}
# Runtime-loaded code is not visible in the main executable's otool output. # Runtime-loaded code is not visible in the main executable's otool output.
module_groups = [ module_groups = [
(prefixes["gdk-pixbuf"] / "lib/gdk-pixbuf-2.0/2.10.0/loaders", "lib/gdk-pixbuf-2.0/2.10.0/loaders"), (prefixes["gdk-pixbuf"] / "lib/gdk-pixbuf-2.0/2.10.0/loaders", "lib/gdk-pixbuf-2.0/2.10.0/loaders"),
@@ -241,15 +265,7 @@ def package(stage: Path, app: Path, build_number: str) -> None:
data = data.replace(str(source), "@ZOITECHAT_RESOURCES@/" + str(target.relative_to(resources))) data = data.replace(str(source), "@ZOITECHAT_RESOURCES@/" + str(target.relative_to(resources)))
(resources / cache).write_text(data + "\n") (resources / cache).write_text(data + "\n")
# The private font map loads the bundled Noto font; ordinary GTK fonts stay local. # The private font map loads the bundled Noto font; ordinary GTK fonts stay local.
(resources / "fonts.conf").write_text('''<?xml version="1.0"?> write_fontconfig(resources, prefixes["fontconfig"])
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<dir>/System/Library/Fonts</dir><dir>/Library/Fonts</dir><dir>~/Library/Fonts</dir>
<cachedir prefix="xdg">fontconfig</cachedir>
<alias><family>sans-serif</family><prefer><family>Helvetica</family></prefer></alias>
<alias><family>monospace</family><prefer><family>Menlo</family></prefer></alias>
</fontconfig>
''')
(resources / "zoitechat-bundle").write_text("1\n") (resources / "zoitechat-bundle").write_text("1\n")
licenses = resources / "licenses" licenses = resources / "licenses"
licenses.mkdir() licenses.mkdir()
+72
View File
@@ -0,0 +1,72 @@
// Capture only the tested process's window; a desktop-only image is not evidence.
import AppKit
import CoreGraphics
import Foundation
func fail(_ message: String) -> Never {
FileHandle.standardError.write(Data((message + "\n").utf8))
exit(1)
}
guard CommandLine.arguments.count == 3,
let pid = Int32(CommandLine.arguments[1]) else {
fail("Usage: capture-window PID OUTPUT.png")
}
let output = URL(fileURLWithPath: CommandLine.arguments[2])
let deadline = Date().addingTimeInterval(30)
var chosen: [String: Any]?
while Date() < deadline {
guard let app = NSRunningApplication(processIdentifier: pid), !app.isTerminated else {
fail("ZoiteChat is not running")
}
app.activate(options: [])
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements],
kCGNullWindowID) as? [[String: Any]] ?? []
let owned = windows.filter { window in
guard (window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == pid,
(window[kCGWindowLayer as String] as? NSNumber)?.intValue == 0,
let bounds = window[kCGWindowBounds as String] as? [String: Any],
let width = bounds["Width"] as? NSNumber,
let height = bounds["Height"] as? NSNumber else { return false }
return width.doubleValue >= 600 && height.doubleValue >= 300
}
chosen = owned.max { left, right in
func area(_ window: [String: Any]) -> Double {
let bounds = window[kCGWindowBounds as String] as? [String: NSNumber] ?? [:]
return (bounds["Width"]?.doubleValue ?? 0) * (bounds["Height"]?.doubleValue ?? 0)
}
return area(left) < area(right)
}
if chosen != nil { break }
}
guard let window = chosen,
let identifier = window[kCGWindowNumber as String] as? NSNumber else {
fail("No visible ZoiteChat window found in this runner's desktop session")
}
let capture = Process()
capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
capture.arguments = ["-x", "-o", "-t", "png", "-l", identifier.stringValue, output.path]
do { try capture.run() } catch { fail("Cannot start screencapture: \(error)") }
capture.waitUntilExit()
guard capture.terminationStatus == 0,
let png = try? Data(contentsOf: output),
let bitmap = NSBitmapImageRep(data: png),
bitmap.pixelsWide >= 600, bitmap.pixelsHigh >= 300 else {
fail("Application window capture failed; check runner Screen Recording permissions")
}
var colors = Set<String>()
for y in stride(from: 0, to: bitmap.pixelsHigh, by: max(1, bitmap.pixelsHigh / 60)) {
for x in stride(from: 0, to: bitmap.pixelsWide, by: max(1, bitmap.pixelsWide / 80)) {
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) {
colors.insert("\(Int(color.redComponent * 255)),\(Int(color.greenComponent * 255)),\(Int(color.blueComponent * 255))")
}
}
}
guard colors.count >= 8 else { fail("Window capture is blank or nearly uniform") }
let evidence: [String: Any] = ["pid": pid, "window_id": identifier.intValue,
"width": bitmap.pixelsWide, "height": bitmap.pixelsHigh, "sampled_colors": colors.count]
do {
let data = try JSONSerialization.data(withJSONObject: evidence, options: [.sortedKeys])
print(String(decoding: data, as: UTF8.self))
} catch { fail("Cannot serialize screenshot evidence: \(error)") }
+47
View File
@@ -1,16 +1,63 @@
"""Dependency parsing, relocation guards and release audit tests without a Mac SDK.""" """Dependency parsing, relocation guards and release audit tests without a Mac SDK."""
from pathlib import Path from pathlib import Path
import os
import shutil import shutil
import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from xml.sax.saxutils import escape
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import bundle import bundle
class BundleTests(unittest.TestCase): class BundleTests(unittest.TestCase):
def test_missing_bitmap_scaling_rules_fail_packaging(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
with self.assertRaisesRegex(RuntimeError, "bitmap scaling rules not found"):
bundle.write_fontconfig(directory / "Resources", directory / "missing-prefix")
@unittest.skipUnless(shutil.which("fc-match"), "requires Fontconfig's matching tool")
def test_bundled_bitmap_scaling_survives_relocation(self):
prefix = Path(bundle.run("brew", "--prefix", "fontconfig")) if sys.platform == "darwin" else Path("/usr")
rule = prefix / "share/fontconfig/conf.avail/10-scale-bitmap-fonts.conf"
if not rule.is_file():
self.skipTest("Fontconfig bitmap scaling configuration unavailable")
font = bundle.ROOT / "data/fonts/NotoColorEmoji.ttf"
with tempfile.TemporaryDirectory(prefix="zoitechat font scaling ") as temporary:
directory = Path(temporary)
source = directory / "build-prefix/share/fontconfig/conf.avail" / rule.name
source.parent.mkdir(parents=True)
shutil.copy2(rule, source)
resources = directory / "Resources"
bundle.write_fontconfig(resources, directory / "build-prefix")
relocated = directory / "Relocated Resources"
shutil.move(resources, relocated)
source.unlink()
# Exercise the shipped configuration without consulting host font
# rules. Only the bundled font is visible after reset-dirs.
wrapper = directory / "probe.conf"
wrapper.write_text(
'<fontconfig><include>' + escape(str(relocated / "fonts.conf")) +
'</include><reset-dirs/><dir>' + escape(str(font.parent)) + '</dir></fontconfig>')
env = os.environ.copy()
env["FONTCONFIG_FILE"] = str(wrapper)
env["XDG_CACHE_HOME"] = str(directory / "cache")
for requested in (12, 16, 24):
output = subprocess.check_output(
["fc-match", "-f", "%{pixelsize}\\n%{matrix}\\n%{file}\\n",
f"Noto Color Emoji:pixelsize={requested}"], env=env, cwd=directory, text=True)
pixels, matrix, matched = output.splitlines()
self.assertEqual(Path(matched).resolve(), font.resolve())
values = [float(value) for value in matrix.split()]
self.assertEqual(len(values), 4, "bitmap scaling matrix missing")
self.assertAlmostEqual(float(pixels) * values[0], requested, places=3)
self.assertAlmostEqual(float(pixels) * values[3], requested, places=3)
self.assertEqual(values[1:3], [0.0, 0.0])
@unittest.skipUnless(sys.platform == "darwin", "requires Apple's Mach-O tools") @unittest.skipUnless(sys.platform == "darwin", "requires Apple's Mach-O tools")
def test_signed_binary_relocation_and_resigning(self): def test_signed_binary_relocation_and_resigning(self):
with tempfile.TemporaryDirectory(prefix="zoitechat relocation ") as temporary: with tempfile.TemporaryDirectory(prefix="zoitechat relocation ") as temporary:
@@ -0,0 +1,85 @@
"""Check that visual-test evidence requires a real IRC handshake and reply."""
import importlib.util
from pathlib import Path
import socket
import threading
import unittest
spec = importlib.util.spec_from_file_location(
"visual_smoke", Path(__file__).resolve().parents[1] / "visual-smoke.py")
visual = importlib.util.module_from_spec(spec)
spec.loader.exec_module(visual)
class VisualSmokeTests(unittest.TestCase):
def test_tls_clienthello_is_rejected_without_waiting_for_a_newline(self):
with visual.IRCServer() as server:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with socket.create_connection(server.server_address, timeout=3) as client:
# A TLS handshake record, with no IRC line terminator.
client.sendall(b"\x16\x03\x03\x00\x00")
self.assertEqual(client.recv(1), b"")
self.assertIn("Received TLS", server.error)
self.assertFalse(server.joined.is_set())
self.assertFalse(server.pong.is_set())
finally:
server.shutdown()
thread.join(timeout=3)
def test_registration_join_and_challenge_reply(self):
with visual.IRCServer() as server:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with socket.create_connection(server.server_address, timeout=3) as client:
stream = client.makefile("rb")
def until(marker):
lines = []
for _ in range(30):
line = stream.readline().decode("utf-8").rstrip()
self.assertTrue(line, "server closed before expected response")
lines.append(line)
if marker in line:
return lines
self.fail("expected response missing: " + marker)
client.sendall(b"CAP LS 302\r\nNICK MacTester\r\nUSER ci 0 * :CI\r\n")
until(" CAP * LS :")
self.assertFalse(server.joined.is_set())
client.sendall(b"CAP END\r\n")
until(" 376 MacTester ")
client.sendall(b"JOIN #ci-smoke\r\n")
messages = until("PING :" + server.token)
self.assertTrue(any("PRIVMSG #ci-smoke" in line for line in messages))
self.assertTrue(server.joined.wait(timeout=1))
self.assertFalse(server.pong.is_set())
client.sendall(b"PONG :wrong-token\r\nPING :barrier\r\n")
until("PONG :barrier")
self.assertFalse(server.pong.is_set())
client.sendall(("PONG :" + server.token + "\r\n").encode())
self.assertTrue(server.pong.wait(timeout=1))
client.sendall(b"QUIT\r\n")
stream.close()
finally:
server.shutdown()
thread.join(timeout=3)
def test_early_disconnect_is_not_success(self):
with visual.IRCServer() as server:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with socket.create_connection(server.server_address, timeout=3) as client:
client.sendall(b"QUIT\r\n")
self.assertFalse(server.joined.is_set())
self.assertFalse(server.pong.is_set())
finally:
server.shutdown()
thread.join(timeout=3)
if __name__ == "__main__":
unittest.main()
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Exercise a relocated Mac bundle against loopback IRC and capture its window."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import platform
import secrets
import shutil
import socketserver
import subprocess
import tempfile
import threading
import time
class IRCServer(socketserver.ThreadingTCPServer):
daemon_threads = True
def __init__(self):
self.joined = threading.Event()
self.pong = threading.Event()
self.error: str | None = None
self.token = secrets.token_hex(12)
self.transcript: list[str] = []
super().__init__(("127.0.0.1", 0), IRCHandler)
class IRCHandler(socketserver.StreamRequestHandler):
def handle(self):
self.connection.settimeout(60)
nick, user, registered, negotiating = "", False, False, False
def send(line):
self.server.transcript.append("S: " + line)
self.wfile.write((line + "\r\n").encode("utf-8"))
self.wfile.flush()
try:
# A TLS ClientHello is binary and need not contain a newline. Do
# not leave the fixture waiting for an IRC line until its timeout.
if self.rfile.peek(1).startswith(b"\x16"):
self.server.error = "Received TLS on the plaintext loopback IRC fixture; launch with SERVER -insecure"
self.server.transcript.append("Error: " + self.server.error)
return
while raw := self.rfile.readline(8192):
line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
self.server.transcript.append("C: " + line)
parts = line.split()
if not parts:
continue
command = parts[0].upper()
if command == "CAP" and len(parts) > 1:
if parts[1].upper() == "LS":
negotiating = True
send(":ci.local CAP * LS :")
elif parts[1].upper() == "END":
negotiating = False
elif parts[1].upper() == "REQ":
send(":ci.local CAP * NAK :" + line.partition(" :")[2])
elif command == "NICK" and len(parts) > 1:
nick = parts[1].lstrip(":")
elif command == "USER":
user = True
elif command == "PING" and len(parts) > 1:
send("PONG :" + parts[-1].lstrip(":"))
elif command == "PONG" and parts[-1].lstrip(":") == self.server.token:
self.server.pong.set()
elif command == "JOIN" and registered:
channel = parts[1].lstrip(":").split(",")[0]
send(f":{nick}!ci@localhost JOIN :{channel}")
send(f":ci.local 332 {nick} {channel} :ZoiteChat native macOS visual check")
send(f":ci.local 353 {nick} = {channel} :@CiGuide {nick}")
send(f":ci.local 366 {nick} {channel} :End of names")
for message in (
"ZoiteChat is running on macOS " + platform.machine() + ".",
"Connected to the temporary IRC server and joined this channel.",
"Unicode rendering: café — 日本語 — 👋",
"This screenshot is captured from the running application window.",
):
send(f":CiGuide!ci@localhost PRIVMSG {channel} :{message}")
send("PING :" + self.server.token)
self.server.joined.set()
elif command == "QUIT":
return
if nick and user and not registered and not negotiating:
registered = True
send(f":ci.local 001 {nick} :Welcome to the ZoiteChat CI test server")
send(f":ci.local 005 {nick} CHANTYPES=# PREFIX=(ov)@+ NETWORK=ZoiteChat-CI :are supported")
send(f":ci.local 376 {nick} :End of MOTD")
except (OSError, ValueError) as error:
self.server.transcript.append("Connection closed: " + str(error))
def stop(process):
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
def check(app: Path, output: Path):
report = {"status": "failed", "architecture": platform.machine(),
"source_commit": os.environ.get("GITHUB_SHA"),
"checks": [], "limitations": "Loopback IRC and window capture; no TLS, input, notifications or stability coverage."}
with IRCServer() as server, tempfile.TemporaryDirectory(prefix="ZoiteChat visual check ") as temporary:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
directory = Path(temporary)
capture = directory / "capture-window"
subprocess.run(["xcrun", "swiftc", str(Path(__file__).with_name("capture-window.swift")),
"-o", str(capture)], check=True, timeout=120)
relocated = directory / "Relocated App/ZoiteChat.app"
shutil.copytree(app.resolve(), relocated)
subprocess.run(["codesign", "--verify", "--deep", "--strict", str(relocated)], check=True, timeout=30)
report["checks"].append("relocated bundle signature verified")
config = directory / "config"
config.mkdir()
(config / "zoitechat.conf").write_text(
"gui_slist_skip = 1\ngui_win_width = 1100\ngui_win_height = 700\n"
"gui_win_left = 60\ngui_win_top = 60\n"
"irc_nick1 = MacTester\nirc_user_name = MacTester\nirc_real_name = Visual check\n"
"net_proxy_type = 0\nidentd_server = 0\n", encoding="utf-8")
binary = relocated / "Contents/MacOS/zoitechat"
with (output / "startup.log").open("w", encoding="utf-8") as log:
# SERVER defaults to TLS even for an irc:// URL in this branch.
# Explicit plaintext is restricted to our loopback-only fixture.
command = f"SERVER -insecure -noproxy irc://127.0.0.1:{server.server_address[1]}/ci-smoke"
process = subprocess.Popen([str(binary), "--no-auto", "--cfgdir", str(config),
"--command", command],
stdout=log, stderr=log)
try:
deadline = time.monotonic() + 60
while not (server.joined.is_set() and server.pong.is_set()):
if server.error:
raise RuntimeError(server.error)
if process.poll() is not None:
raise RuntimeError(f"Application exited with code {process.returncode}; see startup.log")
if time.monotonic() >= deadline:
raise RuntimeError("Timed out waiting for channel join and PING/PONG; see irc.log")
time.sleep(0.2)
report["checks"] += ["connected to loopback IRC", "joined channel", "answered server PING"]
time.sleep(1)
window = subprocess.check_output([str(capture), str(process.pid), str(output / "zoitechat.png")],
text=True, timeout=40)
report["window"] = json.loads(window)
if process.poll() is not None:
raise RuntimeError("Application exited during capture")
report["checks"].append("captured a nonblank application window")
report["status"] = "passed"
finally:
stop(process)
except Exception as error:
report["error"] = str(error)
raise
finally:
server.shutdown()
thread.join(timeout=5)
(output / "irc.log").write_text("\n".join(server.transcript) + "\n", encoding="utf-8")
(output / "result.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("app", type=Path)
parser.add_argument("--output", type=Path, default=Path("dist/visual-check"))
args = parser.parse_args()
if platform.system() != "Darwin":
parser.error("This check must run on macOS with a logged-in desktop")
args.output.mkdir(parents=True, exist_ok=False)
check(args.app, args.output.resolve())
print("Visual check passed; screenshot and evidence saved to " + str(args.output))
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap uap3 uap10 rescap">
<!--
Identity values are filled by build-msix.ps1. For Microsoft Store
submissions they must exactly match Product management > Product identity
in Partner Center.
-->
<Identity
Name="@IDENTITY_NAME@"
Publisher="@PUBLISHER@"
Version="@PACKAGE_VERSION@"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>ZoiteChat</DisplayName>
<PublisherDisplayName>@PUBLISHER_DISPLAY_NAME@</PublisherDisplayName>
<Description>ZoiteChat IRC client</Description>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily
Name="Windows.Desktop"
MinVersion="10.0.19041.0"
MaxVersionTested="10.0.26100.0" />
<PackageDependency
Name="Microsoft.VCLibs.140.00.UWPDesktop"
Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US"
MinVersion="14.0.30704.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
<Applications>
<Application
Id="ZoiteChat"
Executable="ZoiteChat\zoitechat.exe"
uap10:RuntimeBehavior="packagedClassicApp"
uap10:TrustLevel="mediumIL">
<uap:VisualElements
DisplayName="ZoiteChat"
Description="ZoiteChat IRC client"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png" />
<Extensions>
<uap3:Extension Category="windows.protocol">
<uap3:Protocol
Name="irc"
Parameters="--url=&quot;%1&quot;" />
</uap3:Extension>
<uap3:Extension Category="windows.fileTypeAssociation">
<uap3:FileTypeAssociation
Name="zoitechattheme"
Parameters="&quot;%1&quot;">
<uap:DisplayName>ZoiteChat Theme</uap:DisplayName>
<uap:SupportedFileTypes>
<uap:FileType>.zct</uap:FileType>
<uap:FileType>.hct</uap:FileType>
</uap:SupportedFileTypes>
</uap3:FileTypeAssociation>
</uap3:Extension>
</Extensions>
</Application>
</Applications>
</Package>
+392
View File
@@ -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"
+162
View File
@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<!--
This MSI intentionally mirrors the staged portion of the normal Inno Setup
payload. Optional/custom-only files are excluded below. In particular,
portable-mode must never be harvested because copy.vcxproj always stages
that marker. Inno-only download-at-install components remain outside the MSI.
-->
<Package
Name="ZoiteChat"
Manufacturer="ZoiteChat"
Version="$(ProductVersion)"
UpgradeCode="{E9F09DF3-12FA-4BDF-A8AA-A6D62A327ED5}"
Scope="perMachine"
InstallerVersion="500"
Compressed="yes">
<MajorUpgrade
AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of ZoiteChat is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Icon Id="ZoiteChat.ico" SourceFile="$(sys.SOURCEFILEDIR)..\..\data\icons\zoitechat.ico" />
<Property Id="ARPPRODUCTICON" Value="ZoiteChat.ico" />
<Property Id="ARPHELPLINK" Value="https://github.com/ZoiteChat/zoitechat/issues" />
<Property Id="ARPURLINFOABOUT" Value="https://zoitechat.org/" />
<Property Id="ARPURLUPDATEINFO" Value="https://zoitechat.org/downloads.html" />
<Property Id="ARPNOMODIFY" Value="1" />
<!--
The Inno installer can bootstrap the VC++ runtime. A plain MSI should not
run an EXE prerequisite as an MSI custom action, so require the runtime.
Administrative extraction is allowed regardless of prerequisite state.
-->
<Property Id="VCREDIST_X64_INSTALLED">
<RegistrySearch
Id="FindVCRedistX64"
Root="HKLM"
Key="SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64"
Name="Installed"
Type="raw"
Bitness="always64" />
</Property>
<Launch
Condition="Installed OR ACTION = &quot;ADMIN&quot; OR VCREDIST_X64_INSTALLED = &quot;#1&quot;"
Message="Microsoft Visual C++ 2015-2022 x64 Redistributable is required before installing ZoiteChat." />
<Launch
Condition="Installed OR ACTION = &quot;ADMIN&quot; OR VersionNT64 &gt; 601 OR (VersionNT64 = 601 AND ServicePackLevel &gt;= 1)"
Message="ZoiteChat requires 64-bit Windows 7 SP1 or later." />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="ZoiteChat" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="ZoiteChat" />
</StandardDirectory>
<Feature Id="MainFeature" Title="ZoiteChat" Level="1">
<ComponentGroupRef Id="ApplicationFiles" />
<ComponentRef Id="StartMenuShortcuts" />
<ComponentRef Id="ProtocolAndThemeAssociations" />
</Feature>
</Package>
<Fragment>
<ComponentGroup Id="ApplicationFiles">
<Files Directory="INSTALLFOLDER" Include="$(SourceDir)\**">
<!-- Created by copy.vcxproj for Inno's optional portable task only. -->
<Exclude Files="$(SourceDir)\portable-mode" />
<!-- Inno custom-only application component. -->
<Exclude Files="$(SourceDir)\zoitechat-text.exe" />
<!--
Keep the bundled utility and scripting plugins, including Python
3.14 and 3.8 support, available to non-Inno installations.
-->
</Files>
</ComponentGroup>
</Fragment>
<Fragment>
<Component Id="StartMenuShortcuts" Directory="ApplicationProgramsFolder" Guid="*">
<Shortcut
Id="ZoiteChatShortcut"
Name="ZoiteChat"
Description="ZoiteChat IRC client"
Target="[INSTALLFOLDER]zoitechat.exe"
WorkingDirectory="INSTALLFOLDER"
Icon="ZoiteChat.ico">
<ShortcutProperty Key="System.AppUserModel.ID" Value="ZoiteChat.Desktop.Notify" />
</Shortcut>
<Shortcut
Id="ZoiteChatSafeModeShortcut"
Name="ZoiteChat Safe Mode"
Description="Start ZoiteChat without auto-connect or plugins"
Target="[INSTALLFOLDER]zoitechat.exe"
Arguments="--no-auto --no-plugins"
WorkingDirectory="INSTALLFOLDER"
Icon="ZoiteChat.ico" />
<Shortcut
Id="ZoiteChatChangelogShortcut"
Name="ZoiteChat ChangeLog"
Target="[INSTALLFOLDER]changelog.url" />
<Shortcut
Id="ZoiteChatReadmeShortcut"
Name="ZoiteChat ReadMe"
Target="[INSTALLFOLDER]readme.url" />
<Shortcut
Id="ZoiteChatUninstallShortcut"
Name="Uninstall ZoiteChat"
Target="[System64Folder]msiexec.exe"
Arguments="/x [ProductCode]" />
<RemoveFolder Id="RemoveApplicationProgramsFolder" On="uninstall" />
<RegistryValue
Root="HKLM"
Key="Software\ZoiteChat\Installer"
Name="StartMenuShortcuts"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</Fragment>
<Fragment>
<Component Id="ProtocolAndThemeAssociations" Directory="INSTALLFOLDER" Guid="*">
<RegistryValue
Root="HKCR"
Key="irc"
Type="string"
Value="URL:IRC Protocol"
KeyPath="yes" />
<RegistryValue Root="HKCR" Key="irc" Name="URL Protocol" Type="string" Value="" />
<RegistryValue Root="HKCR" Key="irc\DefaultIcon" Type="string" Value="[INSTALLFOLDER]zoitechat.exe,0" />
<RegistryValue Root="HKCR" Key="irc\shell" Type="string" Value="open" />
<RegistryValue
Root="HKCR"
Key="irc\shell\open\command"
Type="string"
Value="&quot;[INSTALLFOLDER]zoitechat.exe&quot; --url=&quot;%1&quot;" />
<RegistryValue Root="HKCR" Key=".zct" Type="string" Value="ZoiteChat.Theme" />
<RegistryValue Root="HKCR" Key=".hct" Type="string" Value="ZoiteChat.Theme" />
<RegistryValue Root="HKCR" Key="ZoiteChat.Theme" Type="string" Value="ZoiteChat Theme" />
<RegistryValue
Root="HKCR"
Key="ZoiteChat.Theme\DefaultIcon"
Type="string"
Value="[INSTALLFOLDER]zoitechat.exe,0" />
<RegistryValue
Root="HKCR"
Key="ZoiteChat.Theme\shell\open\command"
Type="string"
Value="&quot;[INSTALLFOLDER]zoitechat.exe&quot; &quot;%1&quot;" />
</Component>
</Fragment>
</Wix>