diff --git a/.github/workflows/windows-msi-build.yml b/.github/workflows/windows-msi-build.yml new file mode 100644 index 00000000..01f5be1b --- /dev/null +++ b/.github/workflows/windows-msi-build.yml @@ -0,0 +1,382 @@ +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 + + Download-WithRetry -Url https://github.com/ZoiteChat/gvsbuild/releases/download/zoitechat-2.18.1/GTK3_Gvsbuild_zoitechat-2.18.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 + + 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 + ) + + - 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" + } + + # Windows Installer ProductVersion is numeric. Keep the full project + # version in the artifact filename while using major.minor.patch in MSI. + $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' + )) { + $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 version field and requires it to + # be zero in submitted Windows 10/11 app packages. + $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" + + # Microsoft Store identity reserved for ZoiteChat in Partner Center. + # These three values are case-sensitive and must exactly match the + # Product identity page for the Store submission. + $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/src/common/plugin.c b/src/common/plugin.c index ba6528bf..1b51efdf 100644 --- a/src/common/plugin.c +++ b/src/common/plugin.c @@ -86,6 +86,88 @@ plugin_windows_needs_python38 (void) return version.dwMajorVersion < 10; } + +/* + * The Windows scripting plugins are linked against exact runtime DLL names. + * Keep the bridge DLLs installed and resolve the matching system runtimes at + * startup so installing Python/Perl later only requires restarting ZoiteChat. + */ +static gboolean +plugin_windows_dll_available (const wchar_t *dll_name) +{ + return SearchPathW (NULL, dll_name, NULL, 0, NULL, NULL) != 0; +} + +/* + * MSIX packaged processes do not search the normal user PATH for dependent + * DLLs. Official CPython installs register an InstallPath using PEP 514, so + * explicitly load pythonXY.dll from that absolute path before loading the + * ZoiteChat Python bridge. The loaded-module list will then satisfy the + * hcpython*.dll import of the same Python runtime DLL. + */ +static HMODULE +plugin_windows_load_python_runtime_from_registry (const wchar_t *version, + const wchar_t *dll_name) +{ + static const HKEY roots[] = { HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE }; + wchar_t key_name[128]; + wchar_t install_path[32768]; + wchar_t dll_path[32768]; + DWORD install_size; + LONG status; + HMODULE module; + int install_len; + const wchar_t *separator; + guint i; + + if (_snwprintf_s (key_name, G_N_ELEMENTS (key_name), _TRUNCATE, + L"Software\\Python\\PythonCore\\%ls\\InstallPath", + version) < 0) + return NULL; + + for (i = 0; i < G_N_ELEMENTS (roots); i++) + { + install_size = sizeof (install_path); + status = RegGetValueW (roots[i], key_name, NULL, + RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, + NULL, install_path, &install_size); + if (status != ERROR_SUCCESS) + continue; + + install_path[G_N_ELEMENTS (install_path) - 1] = L'\0'; + install_len = lstrlenW (install_path); + if (install_len == 0) + continue; + + separator = (install_path[install_len - 1] == L'\\' || + install_path[install_len - 1] == L'/') ? L"" : L"\\"; + + if (_snwprintf_s (dll_path, G_N_ELEMENTS (dll_path), _TRUNCATE, + L"%ls%ls%ls", install_path, separator, dll_name) < 0) + continue; + + module = LoadLibraryW (dll_path); + if (module != NULL) + return module; + } + + return NULL; +} + +static gboolean +plugin_windows_prepare_python_runtime (const wchar_t *version, + const wchar_t *dll_name) +{ + HMODULE module; + + module = GetModuleHandleW (dll_name); + if (module == NULL) + module = LoadLibraryW (dll_name); + if (module == NULL) + module = plugin_windows_load_python_runtime_from_registry (version, dll_name); + + return module != NULL; +} #endif /* crafted to be an even 32 bytes */ @@ -521,11 +603,18 @@ plugin_auto_load (session *sess) for_files (lib_dir, "hcexec.dll", plugin_auto_load_cb); for_files (lib_dir, "hcfishlim.dll", plugin_auto_load_cb); for_files(lib_dir, "hclua.dll", plugin_auto_load_cb); - for_files (lib_dir, "hcperl.dll", plugin_auto_load_cb); + if (plugin_windows_dll_available (L"perl542.dll")) + for_files (lib_dir, "hcperl.dll", plugin_auto_load_cb); if (plugin_windows_needs_python38 ()) - for_files (lib_dir, "hcpython38.dll", plugin_auto_load_cb); + { + if (plugin_windows_prepare_python_runtime (L"3.8", L"python38.dll")) + for_files (lib_dir, "hcpython38.dll", plugin_auto_load_cb); + } else - for_files (lib_dir, "hcpython3.dll", plugin_auto_load_cb); + { + if (plugin_windows_prepare_python_runtime (L"3.14", L"python314.dll")) + for_files (lib_dir, "hcpython3.dll", plugin_auto_load_cb); + } for_files (lib_dir, "hcupd.dll", plugin_auto_load_cb); for_files (lib_dir, "hcsysinfo.dll", plugin_auto_load_cb); #else diff --git a/src/fe-gtk/fe-gtk.c b/src/fe-gtk/fe-gtk.c index b4afec48..def2ae39 100644 --- a/src/fe-gtk/fe-gtk.c +++ b/src/fe-gtk/fe-gtk.c @@ -124,12 +124,38 @@ create_msg_dialog (gchar *title, gchar *message) static char *win32_argv0_dir; +static gboolean +win32_has_package_identity (void) +{ + HMODULE kernel32; + LONG (WINAPI *get_current_aumid) (UINT32 *, PWSTR); + UINT32 length = 0; + + kernel32 = GetModuleHandleW (L"kernel32.dll"); + if (!kernel32) + return FALSE; + + get_current_aumid = (LONG (WINAPI *) (UINT32 *, PWSTR)) + GetProcAddress (kernel32, "GetCurrentApplicationUserModelId"); + if (!get_current_aumid) + return FALSE; + + return get_current_aumid (&length, NULL) == ERROR_INSUFFICIENT_BUFFER; +} + static void win32_set_appusermodelid (void) { HMODULE shell32; HRESULT (WINAPI *set_appid) (PCWSTR); + /* + * Packaged desktop apps must use the AUMID assigned by the Windows app + * package. Keep the existing explicit AUMID only for Inno/MSI installs. + */ + if (win32_has_package_identity ()) + return; + shell32 = GetModuleHandleW (L"shell32.dll"); if (!shell32) shell32 = LoadLibraryW (L"shell32.dll"); diff --git a/src/fe-gtk/notifications/notification-winrt.cpp b/src/fe-gtk/notifications/notification-winrt.cpp index 1ed1c5f2..c363efdc 100644 --- a/src/fe-gtk/notifications/notification-winrt.cpp +++ b/src/fe-gtk/notifications/notification-winrt.cpp @@ -22,6 +22,8 @@ #include #include +#include +#include #include #include @@ -32,6 +34,30 @@ using namespace Windows::Data::Xml::Dom; static ToastNotifier ^ notifier = nullptr; +static Platform::String ^ +current_package_aumid (void) +{ + HMODULE kernel32; + LONG (WINAPI *get_current_aumid) (UINT32 *, PWSTR); + UINT32 length = 0; + + kernel32 = GetModuleHandleW (L"kernel32.dll"); + if (!kernel32) + return nullptr; + + get_current_aumid = (LONG (WINAPI *) (UINT32 *, PWSTR)) + GetProcAddress (kernel32, "GetCurrentApplicationUserModelId"); + if (!get_current_aumid || + get_current_aumid (&length, nullptr) != ERROR_INSUFFICIENT_BUFFER) + return nullptr; + + std::vector app_id (length); + if (get_current_aumid (&length, app_id.data ()) != ERROR_SUCCESS) + return nullptr; + + return ref new Platform::String (app_id.data ()); +} + static std::wstring widen(const std::string & to_widen) { @@ -87,7 +113,14 @@ extern "C" try { if (!notifier) - notifier = ToastNotificationManager::CreateToastNotifier (L"ZoiteChat.Desktop.Notify"); + { + auto package_aumid = current_package_aumid (); + + if (package_aumid != nullptr) + notifier = ToastNotificationManager::CreateToastNotifier (package_aumid); + else + notifier = ToastNotificationManager::CreateToastNotifier (L"ZoiteChat.Desktop.Notify"); + } } catch (Platform::Exception ^ ex) { 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..7baeaa19 --- /dev/null +++ b/win32/msix/build-msix.ps1 @@ -0,0 +1,388 @@ +[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', + '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\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..3381d303 --- /dev/null +++ b/win32/wix/zoitechat.wxs @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +