mirror of
https://github.com/ZoiteChat/zoitechat.git
synced 2026-11-11 01:45:42 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bae96668a | ||
|
|
9b65e5be73 | ||
|
|
9547dd3711 | ||
|
|
abf49ce756 | ||
|
|
6bf2f34fca | ||
|
|
2ea74498ed | ||
|
|
7004c679f2 | ||
|
|
c9de3f2ce0 | ||
|
|
f77d4f6d59 | ||
|
|
89b9a32168 | ||
|
|
357b766369 | ||
|
|
45a8ba18a6 | ||
|
|
0e0f8f72b2 | ||
|
|
dc5f8f7631 | ||
|
|
3a32b1eae6 | ||
|
|
7bf7e31459 | ||
|
|
c931867c4a | ||
|
|
a2c0eb646f | ||
|
|
8a03585611 | ||
|
|
74d9e35b37 | ||
|
|
9e3daf494f |
No files matched your search
@@ -0,0 +1,37 @@
|
||||
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# Flatpak shared modules
|
||||
- package-ecosystem: "gitsubmodule"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 2
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
# GitHub Actions dependencies
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
groups:
|
||||
actions-minor-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
artifact-metadata: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
build-essential pkg-config meson ninja-build cmake \
|
||||
gettext \
|
||||
libcanberra-dev libglib2.0-dev \
|
||||
libarchive-dev \
|
||||
libarchive-dev libxss-dev \
|
||||
libgtk-3-dev libfontconfig1-dev libpango1.0-dev xvfb xauth dbus-daemon at-spi2-core \
|
||||
libwayland-client0 libwayland-cursor0 libwayland-egl1 \
|
||||
libxkbcommon0 \
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
ninja -C build
|
||||
|
||||
- name: Test emoji catalog, font coverage and picker
|
||||
run: xvfb-run -a dbus-run-session -- meson test -C build --print-errorlogs "Emoji Data Tests" "Emoji Font Tests" "Emoji UI Tests"
|
||||
run: xvfb-run -a dbus-run-session -- meson test -C build --print-errorlogs "Autoaway Tests" "Idle Backend Tests" "Emoji Data Tests" "Emoji Font Tests" "Emoji UI Tests"
|
||||
|
||||
- name: Install to AppDir
|
||||
run: |
|
||||
@@ -312,7 +312,7 @@ jobs:
|
||||
subject-path: Zoitechat-*-x86_64.AppImage
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: zoitechat-appimage
|
||||
path: Zoitechat-*-x86_64.AppImage
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Upload Flatpak Bundle
|
||||
id: upload_flatpak
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: zoitechat.flatpak
|
||||
path: zoitechat.flatpak
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
env:
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install native dependencies
|
||||
run: brew bundle --file packaging/macos/Brewfile
|
||||
- name: Test packaging logic
|
||||
@@ -64,6 +64,20 @@ jobs:
|
||||
run: python3 packaging/macos/build.py --build-number '${{ github.run_number }}'
|
||||
- name: Verify relocated startup
|
||||
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
|
||||
run: ditto -c -k --keepParent dist/ZoiteChat.app dist/ZoiteChat-${{ matrix.arch }}.zip
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
@@ -98,7 +112,7 @@ jobs:
|
||||
env:
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate release settings before building
|
||||
|
||||
@@ -26,9 +26,9 @@ jobs:
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.14.3'
|
||||
architecture: ${{ matrix.arch }}
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
New-Item -Path $pyDir -Name "${{ matrix.platform }}" -ItemType Junction -Value $pyRoot | Out-Null
|
||||
}
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.8.10'
|
||||
architecture: ${{ matrix.arch }}
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
)
|
||||
shell: cmd
|
||||
|
||||
- name: Test emoji catalog, font coverage and picker
|
||||
- name: Test autoaway, emoji catalog, font coverage and picker
|
||||
shell: cmd
|
||||
run: |
|
||||
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat"
|
||||
@@ -213,6 +213,10 @@ jobs:
|
||||
..\zoitechat-build\x64\tests\emoji-%%T-tests.exe --tap
|
||||
if errorlevel 1 exit /b 1
|
||||
)
|
||||
msbuild win32\autoaway-tests.vcxproj /m /verbosity:minimal /p:Configuration=Release /p:Platform=x64
|
||||
if errorlevel 1 exit /b 1
|
||||
..\zoitechat-build\x64\tests\autoaway-tests.exe --tap
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
- name: Preparing Artifacts
|
||||
run: |
|
||||
@@ -222,7 +226,7 @@ jobs:
|
||||
|
||||
- name: Upload Installer
|
||||
id: upload_installer
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Installer ${{ matrix.arch }}
|
||||
path: ZoiteChat-*.exe
|
||||
@@ -236,7 +240,7 @@ jobs:
|
||||
|
||||
- name: Upload Build Files
|
||||
id: upload_buildfiles
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Build Files ${{ matrix.arch }}
|
||||
path: zoitechat-build
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
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@v7
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
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@v7
|
||||
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 autoaway, 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
|
||||
)
|
||||
msbuild win32\autoaway-tests.vcxproj /m /verbosity:minimal /p:Configuration=Release /p:Platform=x64
|
||||
if errorlevel 1 exit /b 1
|
||||
..\zoitechat-build\x64\tests\autoaway-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@v7
|
||||
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@v7
|
||||
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 }}
|
||||
@@ -628,3 +628,38 @@ List of Settings
|
||||
+------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| **url\_logging** | Toggle logging URLs to *<config>/url.log*. |
|
||||
+------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
|
||||
Autoaway
|
||||
--------
|
||||
|
||||
In **Preferences > Chatting > General > Away**, set **Autoaway after** to a
|
||||
number of minutes. The default is 0 (disabled). The separate **Autoaway
|
||||
message** defaults to "Idle". No plugin or scripting language is required.
|
||||
The same options are available immediately through ``/SET``::
|
||||
|
||||
/SET away_auto_timeout 10
|
||||
/SET away_auto_reason Idle
|
||||
/SET away_auto_system ON
|
||||
|
||||
With desktop activity enabled, Windows uses the current login session's last
|
||||
input time, macOS uses CoreGraphics input time, and native X11 uses the
|
||||
XScreenSaver extension if its development library was available at build time.
|
||||
Debian/Ubuntu builders can install ``libxss-dev``; Fedora builders can install
|
||||
``libXScrnSaver-devel``. Meson detects this optional dependency automatically.
|
||||
|
||||
On native Wayland, XWayland, unsupported desktops, and builds without XScreenSaver,
|
||||
autoaway falls back to keyboard, mouse, scroll and touch activity **inside
|
||||
ZoiteChat**. Activity in other applications does not reset that fallback timer.
|
||||
Set ``away_auto_system OFF`` to always use ZoiteChat activity on every platform.
|
||||
The text frontend uses submitted user input. Network traffic, redraws and window
|
||||
focus changes do not count as user activity.
|
||||
|
||||
Autoaway checks every five seconds, after IRC login finishes, independently on
|
||||
each connected network. Returning clears only away states set by autoaway.
|
||||
Manual ``/AWAY`` (including ``/AWAY`` while already automatically away) takes
|
||||
ownership, so activity does not clear it. ``/BACK`` resets the inactivity timer.
|
||||
The existing **Automatically unmark away** setting keeps its separate behaviour
|
||||
when sending messages. Do not run another autoaway script at the same time.
|
||||
Disabling autoaway clears its own away states. Reconnecting re-evaluates current
|
||||
inactivity instead of restoring an outdated automatic away state; manual away
|
||||
restoration is unchanged.
|
||||
@@ -55,7 +55,7 @@
|
||||
"run-tests": true,
|
||||
"test-rule": "",
|
||||
"test-commands": [
|
||||
"meson test --print-errorlogs 'Emoji Data Tests' 'Emoji Font Tests'"
|
||||
"meson test --print-errorlogs 'Autoaway Tests' 'Idle Backend Tests' 'Emoji Data Tests' 'Emoji Font Tests'"
|
||||
],
|
||||
"config-opts": [
|
||||
"-Ddbus-service-use-appid=true",
|
||||
|
||||
+1
-1
Submodule flatpak/shared-modules updated: 55a86b3da4...a58e654b91.
@@ -25,6 +25,7 @@ BuildRequires: pkgconfig(gmodule-2.0)
|
||||
BuildRequires: pkgconfig(gtk+-3.0) >= 3.22
|
||||
BuildRequires: pkgconfig(fontconfig) >= 2.13
|
||||
BuildRequires: pkgconfig(pangoft2) >= 1.44
|
||||
BuildRequires: pkgconfig(xscrnsaver)
|
||||
BuildRequires: pkgconfig(iso-codes)
|
||||
BuildRequires: pkgconfig(libarchive)
|
||||
BuildRequires: pkgconfig(libcanberra) >= 0.22
|
||||
|
||||
@@ -49,6 +49,52 @@ sources and build them in a controlled prefix, or maintain pinned Homebrew formu
|
||||
|
||||
## 2. Verify portability and behavior
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
These defaults apply only to macOS; Linux and Windows shortcuts are unchanged.
|
||||
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| Command+C / X / V | Copy / cut / paste in text fields, including search and preferences |
|
||||
| Command+A | Select all text in the focused field |
|
||||
| Command+Left / Right | Move to the beginning / end of the line |
|
||||
| Option+Left / Right | Move by words |
|
||||
| Command+Up / Down | Move to the beginning / end of the text |
|
||||
| Shift with the movement shortcuts above | Extend the selection |
|
||||
| Option+Delete (Backspace) | Delete back to the beginning of the word |
|
||||
| Command+Delete (Backspace) | Delete back to the beginning of the line |
|
||||
| Command+, | Open Preferences |
|
||||
| Command+M | Minimize the active window |
|
||||
| Control+Tab | Completion in the opposite direction; Tab / Shift+Tab still complete normally |
|
||||
| Command+Option+Left / Right | Move the current tab left / right |
|
||||
| Command+Shift+R | Reset the chat marker; Command+Shift+M still moves to the marker |
|
||||
|
||||
The inherited Control+C/X/V clipboard bindings are removed on macOS only.
|
||||
Command+C/X/V in multiline editors use plain UTF-8 text directly, avoiding
|
||||
GTK3 Quartz's incomplete rich-text negotiation and pasteboard types without a
|
||||
preferred MIME name. Text, newlines, selection replacement and editability
|
||||
checks are preserved; rich clipboard formatting is not imported by this action.
|
||||
Command+C copies selected input text first; if no input text is selected, it
|
||||
copies the selected chat text. Command+X cuts editable text, never messages
|
||||
from chat history. Password protection and read-only fields retain GTK's
|
||||
existing behavior. Undo/redo is not added: GTK3's entry widget needs a separate
|
||||
editing-history implementation before Command+Z can work reliably.
|
||||
|
||||
Exact old default assignments for Command+Tab, Option+Left/Right and Command+M
|
||||
are remapped in memory when loading defaults or an existing configuration.
|
||||
Other custom bindings are preserved. If a replacement key is already assigned,
|
||||
both existing assignments are left alone; resolve that conflict in **Settings
|
||||
→ Keyboard Shortcuts**. Saving there persists the updated assignments.
|
||||
Command+Tab is left to macOS for switching applications. Plugins and custom
|
||||
input bindings retain priority; the new native menu accelerators are omitted
|
||||
when the same key is already assigned to an input action.
|
||||
|
||||
Before distributing a build, test the shortcuts in message input, search,
|
||||
preferences, a detached chat window and a multiline editor, including Unicode,
|
||||
selection replacement, and read-only/password fields. The Meson widget tests
|
||||
exercise the GTK actions; the screenshot smoke check does not send real keys
|
||||
through the macOS event loop.
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s packaging/macos/tests -v
|
||||
python3 packaging/macos/bundle.py audit dist/ZoiteChat.app
|
||||
@@ -59,6 +105,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
|
||||
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
|
||||
modules, GIO TLS, Enchant providers, OpenSSL providers and ZoiteChat plugins. It
|
||||
fails on missing libraries, external symlinks/search paths, architecture mismatches
|
||||
|
||||
+26
-10
@@ -12,6 +12,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
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)
|
||||
|
||||
|
||||
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]:
|
||||
binary = app / "Contents/MacOS/zoitechat"
|
||||
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))
|
||||
|
||||
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.
|
||||
module_groups = [
|
||||
(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)))
|
||||
(resources / cache).write_text(data + "\n")
|
||||
# The private font map loads the bundled Noto font; ordinary GTK fonts stay local.
|
||||
(resources / "fonts.conf").write_text('''<?xml version="1.0"?>
|
||||
<!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>
|
||||
''')
|
||||
write_fontconfig(resources, prefixes["fontconfig"])
|
||||
(resources / "zoitechat-bundle").write_text("1\n")
|
||||
licenses = resources / "licenses"
|
||||
licenses.mkdir()
|
||||
|
||||
@@ -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)") }
|
||||
@@ -1,16 +1,63 @@
|
||||
"""Dependency parsing, relocation guards and release audit tests without a Mac SDK."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import bundle
|
||||
|
||||
|
||||
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")
|
||||
def test_signed_binary_relocation_and_resigning(self):
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,135 @@
|
||||
/* ZoiteChat
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#include "zoitechat.h"
|
||||
#include "zoitechatc.h"
|
||||
#include "server.h"
|
||||
#include "fe.h"
|
||||
#include "autoaway.h"
|
||||
|
||||
static gint64 last_activity;
|
||||
static int timer_tag;
|
||||
static int previous_timeout;
|
||||
static unsigned int previous_system;
|
||||
|
||||
void
|
||||
autoaway_activity (void)
|
||||
{
|
||||
last_activity = g_get_monotonic_time ();
|
||||
}
|
||||
|
||||
/* A manual /AWAY takes ownership even if the server already says away. */
|
||||
void
|
||||
autoaway_manual (server *serv)
|
||||
{
|
||||
serv->autoaway_owned = FALSE;
|
||||
autoaway_activity ();
|
||||
}
|
||||
|
||||
void
|
||||
autoaway_reset (server *serv)
|
||||
{
|
||||
if (!serv->autoaway_owned)
|
||||
return;
|
||||
|
||||
serv->autoaway_owned = FALSE;
|
||||
serv->reconnect_away = FALSE;
|
||||
g_clear_pointer (&serv->last_away_reason, g_free);
|
||||
}
|
||||
|
||||
static void
|
||||
autoaway_release (server *serv)
|
||||
{
|
||||
if (!serv->autoaway_owned)
|
||||
return;
|
||||
|
||||
/* Cancel even an unacknowledged AWAY. IRC replies may arrive later. */
|
||||
if (serv->connected && serv->end_of_motd)
|
||||
serv->p_set_back (serv);
|
||||
autoaway_reset (serv);
|
||||
}
|
||||
|
||||
void
|
||||
autoaway_check (gint64 now, gint64 desktop_idle)
|
||||
{
|
||||
GSList *list;
|
||||
gint64 idle = MAX ((gint64)0, now - last_activity);
|
||||
gint64 limit = (gint64)prefs.hex_away_auto_timeout * 60 * G_USEC_PER_SEC;
|
||||
|
||||
if (prefs.hex_away_auto_system && desktop_idle >= 0)
|
||||
idle = MIN (idle, desktop_idle);
|
||||
|
||||
for (list = serv_list; list; list = list->next)
|
||||
{
|
||||
server *serv = list->data;
|
||||
|
||||
if (!serv->connected)
|
||||
{
|
||||
/* Re-evaluate idle after login instead of restoring stale autoaway. */
|
||||
autoaway_reset (serv);
|
||||
continue;
|
||||
}
|
||||
if (!serv->end_of_motd)
|
||||
continue;
|
||||
|
||||
if (limit <= 0 || idle < limit)
|
||||
{
|
||||
autoaway_release (serv);
|
||||
continue;
|
||||
}
|
||||
if (serv->is_away || serv->reconnect_away)
|
||||
continue;
|
||||
|
||||
/* Retry a rejected/lost request slowly, never once per poll. */
|
||||
if (serv->autoaway_owned &&
|
||||
now - serv->autoaway_sent_at < 30 * G_USEC_PER_SEC)
|
||||
continue;
|
||||
|
||||
g_free (serv->last_away_reason);
|
||||
serv->last_away_reason = g_strdup (prefs.hex_away_auto_reason[0] ?
|
||||
prefs.hex_away_auto_reason : _("Idle"));
|
||||
serv->autoaway_owned = TRUE;
|
||||
serv->autoaway_sent_at = now;
|
||||
serv->p_set_away (serv, serv->last_away_reason);
|
||||
}
|
||||
}
|
||||
|
||||
static int
|
||||
autoaway_timer (void)
|
||||
{
|
||||
autoaway_check (g_get_monotonic_time (),
|
||||
prefs.hex_away_auto_system ? fe_get_idle_time () : -1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void
|
||||
autoaway_reinit (void)
|
||||
{
|
||||
if (previous_timeout != prefs.hex_away_auto_timeout ||
|
||||
previous_system != prefs.hex_away_auto_system)
|
||||
{
|
||||
autoaway_activity ();
|
||||
previous_timeout = prefs.hex_away_auto_timeout;
|
||||
previous_system = prefs.hex_away_auto_system;
|
||||
}
|
||||
|
||||
if (prefs.hex_away_auto_timeout > 0)
|
||||
{
|
||||
if (!timer_tag)
|
||||
{
|
||||
autoaway_activity ();
|
||||
timer_tag = fe_timeout_add_seconds (5, autoaway_timer, NULL);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (timer_tag)
|
||||
{
|
||||
fe_timeout_remove (timer_tag);
|
||||
timer_tag = 0;
|
||||
}
|
||||
/* Disabling autoaway must also work without another input event. */
|
||||
autoaway_check (g_get_monotonic_time (), -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* ZoiteChat
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#ifndef ZOITECHAT_AUTOAWAY_H
|
||||
#define ZOITECHAT_AUTOAWAY_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
struct server;
|
||||
|
||||
void autoaway_activity (void);
|
||||
void autoaway_manual (struct server *serv);
|
||||
void autoaway_reset (struct server *serv);
|
||||
void autoaway_reinit (void);
|
||||
/* Both samples are in microseconds; -1 means desktop idle is unavailable. */
|
||||
void autoaway_check (gint64 now, gint64 desktop_idle);
|
||||
|
||||
#endif
|
||||
@@ -29,6 +29,7 @@
|
||||
#include "fe.h"
|
||||
#include "text.h"
|
||||
#include "zoitechatc.h"
|
||||
#include "autoaway.h"
|
||||
#include "typedef.h"
|
||||
|
||||
#ifdef WIN32
|
||||
@@ -359,6 +360,9 @@ default_file (void)
|
||||
|
||||
const struct prefs vars[] =
|
||||
{
|
||||
{"away_auto_reason", P_OFFSET (hex_away_auto_reason), TYPE_STR},
|
||||
{"away_auto_system", P_OFFINT (hex_away_auto_system), TYPE_BOOL, autoaway_reinit},
|
||||
{"away_auto_timeout", P_OFFINT (hex_away_auto_timeout), TYPE_INT, autoaway_reinit},
|
||||
{"away_auto_unmark", P_OFFINT (hex_away_auto_unmark), TYPE_BOOL},
|
||||
{"away_omit_alerts", P_OFFINT (hex_away_omit_alerts), TYPE_BOOL},
|
||||
{"away_reason", P_OFFSET (hex_away_reason), TYPE_STR},
|
||||
@@ -767,6 +771,7 @@ load_default_config(void)
|
||||
/* put in default values, anything left out is automatically zero */
|
||||
|
||||
/* BOOLEANS */
|
||||
prefs.hex_away_auto_system = 1;
|
||||
prefs.hex_away_show_once = 1;
|
||||
prefs.hex_away_track = 1;
|
||||
prefs.hex_dcc_auto_resume = 1;
|
||||
@@ -887,6 +892,7 @@ load_default_config(void)
|
||||
prefs.hex_url_grabber_limit = 100; /* 0 means unlimited */
|
||||
|
||||
/* STRINGS */
|
||||
strcpy (prefs.hex_away_auto_reason, _("Idle"));
|
||||
strcpy (prefs.hex_away_reason, _("I'm busy"));
|
||||
strcpy (prefs.hex_completion_suffix, ",");
|
||||
#ifdef WIN32
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="autoaway.h" />
|
||||
<ClInclude Include="cfgfiles.h" />
|
||||
<ClInclude Include="chanopt.h" />
|
||||
<ClInclude Include="ctcp.h" />
|
||||
@@ -52,6 +53,7 @@
|
||||
<ClInclude Include="gtk3-theme-service.h" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="autoaway.c" />
|
||||
<ClCompile Include="cfgfiles.c" />
|
||||
<ClCompile Include="chanopt.c" />
|
||||
<ClCompile Include="ctcp.c" />
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="autoaway.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="cfgfiles.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
@@ -136,6 +139,9 @@
|
||||
</ClInclude>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="autoaway.c">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="cfgfiles.c">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
|
||||
@@ -142,6 +142,7 @@ void fe_lastlog (session *sess, session *lastlog_sess, char *sstr, gtk_xtext_sea
|
||||
void fe_set_lag (server *serv, long lag);
|
||||
void fe_set_throttle (server *serv);
|
||||
void fe_set_away (server *serv);
|
||||
gint64 fe_get_idle_time (void);
|
||||
void fe_serverlist_open (session *sess);
|
||||
void fe_get_bool (char *title, char *prompt, void *callback, void *userdata);
|
||||
void fe_get_str (char *prompt, char *def, void *callback, void *ud);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
common_sources = [
|
||||
'autoaway.c',
|
||||
'cfgfiles.c',
|
||||
'chanopt.c',
|
||||
'ctcp.c',
|
||||
@@ -224,3 +225,10 @@ test('Hex Color Tests', hexcolor_tests,
|
||||
protocol: 'tap',
|
||||
timeout: 120,
|
||||
)
|
||||
|
||||
autoaway_tests = executable('autoaway_tests',
|
||||
['tests/test-autoaway.c', 'autoaway.c'],
|
||||
include_directories: [config_h_include, include_directories('.')],
|
||||
dependencies: zoitechat_common_header_deps,
|
||||
)
|
||||
test('Autoaway Tests', autoaway_tests, protocol: 'tap', timeout: 30)
|
||||
+12
-1
@@ -54,6 +54,7 @@
|
||||
#include "server.h"
|
||||
#include "tree.h"
|
||||
#include "outbound.h"
|
||||
#include "autoaway.h"
|
||||
#include "chanopt.h"
|
||||
|
||||
#define TBUFSIZE 4096
|
||||
@@ -360,6 +361,10 @@ cmd_away (struct session *sess, char *tbuf, char *word[], char *word_eol[])
|
||||
{
|
||||
char *reason = word_eol[2];
|
||||
|
||||
/* Restoring a manual away after reconnect is not new user input. */
|
||||
if (!sess->server->reconnect_away)
|
||||
autoaway_manual (sess->server);
|
||||
|
||||
if (!(*reason))
|
||||
{
|
||||
if (sess->server->is_away)
|
||||
@@ -396,7 +401,10 @@ cmd_away (struct session *sess, char *tbuf, char *word[], char *word_eol[])
|
||||
static int
|
||||
cmd_back (struct session *sess, char *tbuf, char *word[], char *word_eol[])
|
||||
{
|
||||
if (sess->server->is_away)
|
||||
gboolean was_autoaway = sess->server->autoaway_owned;
|
||||
|
||||
autoaway_manual (sess->server);
|
||||
if (sess->server->is_away || was_autoaway)
|
||||
{
|
||||
sess->server->p_set_back (sess->server);
|
||||
}
|
||||
@@ -5051,6 +5059,9 @@ handle_user_input (session *sess, char *text, int history, int nocommand)
|
||||
if (*text == '\0')
|
||||
return 1;
|
||||
|
||||
if (history)
|
||||
autoaway_activity ();
|
||||
|
||||
if (history)
|
||||
history_add (&sess->history, text);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
#include "zoitechat.h"
|
||||
#include "proto-irc.h"
|
||||
#include "autoaway.h"
|
||||
#include "ctcp.h"
|
||||
#include "fe.h"
|
||||
#include "ignore.h"
|
||||
@@ -438,6 +439,12 @@ irc_raw (server *serv, char *raw)
|
||||
{
|
||||
int len;
|
||||
char tbuf[8704];
|
||||
|
||||
/* /QUOTE AWAY and scripts sending raw AWAY also take manual ownership. */
|
||||
if (!g_ascii_strncasecmp (raw, "AWAY", 4) &&
|
||||
(raw[4] == 0 || raw[4] == ' '))
|
||||
autoaway_manual (serv);
|
||||
|
||||
if (*raw)
|
||||
{
|
||||
len = strlen (raw);
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
#include "proto-irc.h"
|
||||
#include "servlist.h"
|
||||
#include "server.h"
|
||||
#include "autoaway.h"
|
||||
#include "sts.h"
|
||||
|
||||
#ifdef USE_OPENSSL
|
||||
@@ -1873,6 +1874,7 @@ is_server (server *serv)
|
||||
void
|
||||
server_set_defaults (server *serv)
|
||||
{
|
||||
autoaway_reset (serv);
|
||||
g_free (serv->chantypes);
|
||||
g_clear_pointer (&serv->clienttagdeny, g_free);
|
||||
g_free (serv->chanmodes);
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/* ZoiteChat
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include "../zoitechat.h"
|
||||
#include "../autoaway.h"
|
||||
|
||||
struct zoitechatprefs prefs;
|
||||
GSList *serv_list;
|
||||
static server servers[3];
|
||||
static int away_count, back_count, timer_adds, timer_removes;
|
||||
static gint64 base;
|
||||
static char *sent_reason;
|
||||
|
||||
/* Protocol stubs deliberately do not acknowledge requests. */
|
||||
static void
|
||||
send_away (server *serv, char *reason)
|
||||
{
|
||||
away_count++;
|
||||
g_free (sent_reason);
|
||||
sent_reason = g_strdup (reason);
|
||||
}
|
||||
|
||||
static void
|
||||
send_back (server *serv)
|
||||
{
|
||||
back_count++;
|
||||
}
|
||||
|
||||
int
|
||||
fe_timeout_add_seconds (int interval, void *callback, void *data)
|
||||
{
|
||||
g_assert_cmpint (interval, ==, 5);
|
||||
timer_adds++;
|
||||
return 42;
|
||||
}
|
||||
|
||||
void
|
||||
fe_timeout_remove (int tag)
|
||||
{
|
||||
g_assert_cmpint (tag, ==, 42);
|
||||
timer_removes++;
|
||||
}
|
||||
|
||||
gint64
|
||||
fe_get_idle_time (void)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void
|
||||
setup (void)
|
||||
{
|
||||
int i;
|
||||
|
||||
prefs.hex_away_auto_timeout = 0;
|
||||
autoaway_reinit ();
|
||||
for (i = 0; i < 3; i++)
|
||||
g_free (servers[i].last_away_reason);
|
||||
g_slist_free (serv_list);
|
||||
serv_list = NULL;
|
||||
memset (servers, 0, sizeof servers);
|
||||
memset (&prefs, 0, sizeof prefs);
|
||||
away_count = back_count = timer_adds = timer_removes = 0;
|
||||
g_clear_pointer (&sent_reason, g_free);
|
||||
prefs.hex_away_auto_timeout = 10;
|
||||
prefs.hex_away_auto_system = TRUE;
|
||||
g_strlcpy (prefs.hex_away_auto_reason, "Auto idle", sizeof prefs.hex_away_auto_reason);
|
||||
for (i = 0; i < 3; i++)
|
||||
{
|
||||
servers[i].connected = TRUE;
|
||||
servers[i].end_of_motd = TRUE;
|
||||
servers[i].p_set_away = send_away;
|
||||
servers[i].p_set_back = send_back;
|
||||
}
|
||||
serv_list = g_slist_append (NULL, &servers[0]);
|
||||
autoaway_activity ();
|
||||
base = g_get_monotonic_time ();
|
||||
}
|
||||
|
||||
static void
|
||||
test_fallback_boundary (void)
|
||||
{
|
||||
setup ();
|
||||
autoaway_check (base + 599 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 0);
|
||||
autoaway_check (base + 600 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 1);
|
||||
g_assert_true (servers[0].autoaway_owned);
|
||||
g_assert_cmpstr (sent_reason, ==, "Auto idle");
|
||||
/* An ACK is not required to avoid flooding the connection. */
|
||||
autoaway_check (base + 605 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 1);
|
||||
servers[0].is_away = TRUE;
|
||||
autoaway_check (base + 640 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 1);
|
||||
autoaway_activity ();
|
||||
autoaway_check (g_get_monotonic_time (), -1);
|
||||
g_assert_cmpint (back_count, ==, 1);
|
||||
g_assert_false (servers[0].autoaway_owned);
|
||||
g_assert_null (servers[0].last_away_reason);
|
||||
}
|
||||
|
||||
static void
|
||||
test_desktop_activity (void)
|
||||
{
|
||||
setup ();
|
||||
autoaway_check (base + 900 * G_USEC_PER_SEC, 5 * G_USEC_PER_SEC);
|
||||
g_assert_cmpint (away_count, ==, 0);
|
||||
autoaway_check (base + 900 * G_USEC_PER_SEC, 700 * G_USEC_PER_SEC);
|
||||
g_assert_cmpint (away_count, ==, 1);
|
||||
servers[0].is_away = TRUE;
|
||||
autoaway_check (base + 905 * G_USEC_PER_SEC, 0);
|
||||
g_assert_cmpint (back_count, ==, 1);
|
||||
/* App input wins even if the native backend still has an old sample. */
|
||||
autoaway_activity ();
|
||||
autoaway_check (g_get_monotonic_time (), 900 * G_USEC_PER_SEC);
|
||||
g_assert_cmpint (away_count, ==, 1);
|
||||
}
|
||||
|
||||
static void
|
||||
test_app_mode (void)
|
||||
{
|
||||
setup ();
|
||||
prefs.hex_away_auto_system = FALSE;
|
||||
autoaway_check (base + 600 * G_USEC_PER_SEC, 0);
|
||||
g_assert_cmpint (away_count, ==, 1);
|
||||
}
|
||||
|
||||
static void
|
||||
test_manual_away (void)
|
||||
{
|
||||
setup ();
|
||||
servers[0].is_away = TRUE;
|
||||
servers[0].last_away_reason = g_strdup ("Lunch");
|
||||
autoaway_check (base + 900 * G_USEC_PER_SEC, -1);
|
||||
autoaway_check (base + 905 * G_USEC_PER_SEC, 0);
|
||||
prefs.hex_away_auto_timeout = 0;
|
||||
autoaway_reinit ();
|
||||
g_assert_cmpint (away_count, ==, 0);
|
||||
g_assert_cmpint (back_count, ==, 0);
|
||||
g_assert_cmpstr (servers[0].last_away_reason, ==, "Lunch");
|
||||
}
|
||||
|
||||
static void
|
||||
test_manual_takeover (void)
|
||||
{
|
||||
setup ();
|
||||
autoaway_check (base + 600 * G_USEC_PER_SEC, -1);
|
||||
servers[0].is_away = TRUE;
|
||||
/* This is also called for /AWAY with no reason, even if already away. */
|
||||
autoaway_manual (&servers[0]);
|
||||
autoaway_check (g_get_monotonic_time (), 0);
|
||||
prefs.hex_away_auto_timeout = 0;
|
||||
autoaway_reinit ();
|
||||
g_assert_cmpint (back_count, ==, 0);
|
||||
g_assert_true (servers[0].is_away);
|
||||
}
|
||||
|
||||
static void
|
||||
test_pending_away_cancel (void)
|
||||
{
|
||||
setup ();
|
||||
autoaway_check (base + 600 * G_USEC_PER_SEC, -1);
|
||||
g_assert_false (servers[0].is_away);
|
||||
autoaway_activity ();
|
||||
autoaway_check (g_get_monotonic_time (), -1);
|
||||
g_assert_cmpint (back_count, ==, 1);
|
||||
/* A late 306 must not make us own the away state again. */
|
||||
servers[0].is_away = TRUE;
|
||||
autoaway_check (g_get_monotonic_time (), -1);
|
||||
g_assert_cmpint (back_count, ==, 1);
|
||||
}
|
||||
|
||||
static void
|
||||
test_login_and_multiple_servers (void)
|
||||
{
|
||||
setup ();
|
||||
serv_list = g_slist_append (serv_list, &servers[1]);
|
||||
serv_list = g_slist_append (serv_list, &servers[2]);
|
||||
servers[1].is_away = TRUE;
|
||||
servers[1].last_away_reason = g_strdup ("Manual");
|
||||
servers[2].end_of_motd = FALSE;
|
||||
autoaway_check (base + 600 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 1);
|
||||
servers[2].end_of_motd = TRUE;
|
||||
autoaway_check (base + 605 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 2);
|
||||
autoaway_check (base + 610 * G_USEC_PER_SEC, 0);
|
||||
g_assert_cmpint (back_count, ==, 2);
|
||||
g_assert_cmpstr (servers[1].last_away_reason, ==, "Manual");
|
||||
}
|
||||
|
||||
static void
|
||||
test_disconnect_and_reconnect (void)
|
||||
{
|
||||
setup ();
|
||||
autoaway_check (base + 600 * G_USEC_PER_SEC, -1);
|
||||
servers[0].connected = FALSE;
|
||||
servers[0].is_away = TRUE;
|
||||
servers[0].reconnect_away = TRUE;
|
||||
autoaway_check (base + 605 * G_USEC_PER_SEC, -1);
|
||||
g_assert_false (servers[0].autoaway_owned);
|
||||
g_assert_false (servers[0].reconnect_away);
|
||||
g_assert_null (servers[0].last_away_reason);
|
||||
g_assert_cmpint (back_count, ==, 0);
|
||||
servers[0].is_away = FALSE;
|
||||
servers[0].connected = TRUE;
|
||||
servers[0].end_of_motd = FALSE;
|
||||
autoaway_check (base + 610 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 1);
|
||||
servers[0].end_of_motd = TRUE;
|
||||
autoaway_check (base + 615 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 2);
|
||||
/* server_set_defaults resets ownership even before the next timer tick. */
|
||||
servers[0].reconnect_away = TRUE;
|
||||
autoaway_reset (&servers[0]);
|
||||
g_assert_false (servers[0].reconnect_away);
|
||||
/* Existing manual reconnect-away must be left alone. */
|
||||
servers[0].reconnect_away = TRUE;
|
||||
servers[0].last_away_reason = g_strdup ("Manual");
|
||||
autoaway_reset (&servers[0]);
|
||||
g_assert_true (servers[0].reconnect_away);
|
||||
g_assert_cmpstr (servers[0].last_away_reason, ==, "Manual");
|
||||
}
|
||||
|
||||
static void
|
||||
test_retry (void)
|
||||
{
|
||||
setup ();
|
||||
autoaway_check (base + 600 * G_USEC_PER_SEC, -1);
|
||||
autoaway_check (base + 629 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 1);
|
||||
autoaway_check (base + 630 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 2);
|
||||
}
|
||||
|
||||
static void
|
||||
test_disable_and_timers (void)
|
||||
{
|
||||
setup ();
|
||||
autoaway_reinit ();
|
||||
autoaway_reinit ();
|
||||
g_assert_cmpint (timer_adds, ==, 1);
|
||||
base = g_get_monotonic_time ();
|
||||
autoaway_check (base + 600 * G_USEC_PER_SEC, -1);
|
||||
prefs.hex_away_auto_timeout = 0;
|
||||
autoaway_reinit ();
|
||||
g_assert_cmpint (back_count, ==, 1);
|
||||
g_assert_cmpint (timer_removes, ==, 1);
|
||||
g_assert_false (servers[0].autoaway_owned);
|
||||
autoaway_reinit ();
|
||||
g_assert_cmpint (back_count, ==, 1);
|
||||
prefs.hex_away_auto_timeout = -1;
|
||||
autoaway_reinit ();
|
||||
g_assert_cmpint (away_count, ==, 1);
|
||||
}
|
||||
|
||||
static void
|
||||
test_empty_reason_and_large_timeout (void)
|
||||
{
|
||||
setup ();
|
||||
prefs.hex_away_auto_reason[0] = 0;
|
||||
autoaway_check (base + 600 * G_USEC_PER_SEC, -1);
|
||||
g_assert_nonnull (sent_reason);
|
||||
g_assert_cmpstr (sent_reason, !=, "");
|
||||
setup ();
|
||||
prefs.hex_away_auto_timeout = G_MAXINT;
|
||||
autoaway_check (base + 900 * G_USEC_PER_SEC, -1);
|
||||
g_assert_cmpint (away_count, ==, 0);
|
||||
}
|
||||
|
||||
int
|
||||
main (int argc, char **argv)
|
||||
{
|
||||
int result, i;
|
||||
g_test_init (&argc, &argv, NULL);
|
||||
g_test_add_func ("/autoaway/fallback-boundary", test_fallback_boundary);
|
||||
g_test_add_func ("/autoaway/desktop-activity", test_desktop_activity);
|
||||
g_test_add_func ("/autoaway/app-mode", test_app_mode);
|
||||
g_test_add_func ("/autoaway/manual-away", test_manual_away);
|
||||
g_test_add_func ("/autoaway/manual-takeover", test_manual_takeover);
|
||||
g_test_add_func ("/autoaway/pending-cancel", test_pending_away_cancel);
|
||||
g_test_add_func ("/autoaway/login-multiple-servers", test_login_and_multiple_servers);
|
||||
g_test_add_func ("/autoaway/reconnect", test_disconnect_and_reconnect);
|
||||
g_test_add_func ("/autoaway/retry", test_retry);
|
||||
g_test_add_func ("/autoaway/disable-timers", test_disable_and_timers);
|
||||
g_test_add_func ("/autoaway/empty-reason-large-timeout", test_empty_reason_and_large_timeout);
|
||||
result = g_test_run ();
|
||||
for (i = 0; i < 3; i++)
|
||||
g_free (servers[i].last_away_reason);
|
||||
g_free (sent_reason);
|
||||
g_slist_free (serv_list);
|
||||
return result;
|
||||
}
|
||||
@@ -46,6 +46,7 @@
|
||||
#include "plugin.h"
|
||||
#include "plugin-identd.h"
|
||||
#include "plugin-timer.h"
|
||||
#include "autoaway.h"
|
||||
#include "notify.h"
|
||||
#include "server.h"
|
||||
#include "servlist.h"
|
||||
@@ -499,6 +500,8 @@ zoitechat_reinit_timers (void)
|
||||
static int lag_check_tag = 0;
|
||||
static int away_tag = 0;
|
||||
|
||||
autoaway_reinit ();
|
||||
|
||||
if (prefs.hex_notify_timeout && notify_tag == 0)
|
||||
{
|
||||
notify_tag = fe_timeout_add_seconds (prefs.hex_notify_timeout,
|
||||
|
||||
@@ -105,6 +105,7 @@ struct zoitechatprefs
|
||||
/* these are the rebranded, consistent, sorted zoitechat variables */
|
||||
|
||||
/* BOOLEANS */
|
||||
unsigned int hex_away_auto_system;
|
||||
unsigned int hex_away_auto_unmark;
|
||||
unsigned int hex_away_omit_alerts;
|
||||
unsigned int hex_away_show_once;
|
||||
@@ -233,6 +234,7 @@ struct zoitechatprefs
|
||||
unsigned int hex_url_logging;
|
||||
|
||||
/* NUMBERS */
|
||||
int hex_away_auto_timeout;
|
||||
int hex_away_size_max;
|
||||
int hex_away_timeout;
|
||||
int hex_completion_amount;
|
||||
@@ -311,6 +313,7 @@ struct zoitechatprefs
|
||||
int hex_url_grabber_limit;
|
||||
|
||||
/* STRINGS */
|
||||
char hex_away_auto_reason[256];
|
||||
char hex_away_reason[256];
|
||||
char hex_completion_suffix[4]; /* Only ever holds a one-character string. */
|
||||
char hex_dcc_completed_dir[PATHLEN + 1];
|
||||
@@ -597,6 +600,7 @@ typedef struct server
|
||||
/*time_t connect_time;*/ /* when did it connect? */
|
||||
unsigned long lag_sent; /* we are still waiting for this ping response*/
|
||||
time_t ping_recv; /* when we last got a ping reply */
|
||||
gint64 autoaway_sent_at;
|
||||
time_t away_time; /* when we were marked away */
|
||||
|
||||
char *encoding;
|
||||
@@ -616,6 +620,7 @@ typedef struct server
|
||||
unsigned int end_of_motd:1; /* end of motd reached (logged in) */
|
||||
unsigned int sent_quit:1; /* sent a QUIT already? */
|
||||
unsigned int use_listargs:1; /* undernet and dalnet need /list >0,<10000 */
|
||||
unsigned int autoaway_owned:1;
|
||||
unsigned int is_away:1;
|
||||
unsigned int reconnect_away:1; /* whether to reconnect in is_away state */
|
||||
unsigned int dont_use_proxy:1; /* to proxy or not to proxy */
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "fe-gtk.h"
|
||||
#ifdef __APPLE__
|
||||
#include "../common/macos.h"
|
||||
#include "macos-shortcuts.h"
|
||||
#endif
|
||||
|
||||
#ifdef GDK_WINDOWING_WIN32
|
||||
@@ -58,6 +59,7 @@
|
||||
#include "notifygui.h"
|
||||
#include "textgui.h"
|
||||
#include "fkeys.h"
|
||||
#include "idle.h"
|
||||
#include "plugin-tray.h"
|
||||
#include "urlgrab.h"
|
||||
#include "setup.h"
|
||||
@@ -503,6 +505,8 @@ fe_args (int argc, char *argv[])
|
||||
gtk_init (&argc, &argv);
|
||||
|
||||
#ifdef __APPLE__
|
||||
macos_shortcuts_init (setup_open);
|
||||
|
||||
if (g_getenv ("ZOITECHAT_MACOS_SHARE"))
|
||||
{
|
||||
char *icons = g_build_filename (g_getenv ("ZOITECHAT_MACOS_SHARE"), "icons", NULL);
|
||||
@@ -635,6 +639,7 @@ fe_init (void)
|
||||
{
|
||||
theme_manager_init ();
|
||||
key_init ();
|
||||
idle_init ();
|
||||
pixmaps_init ();
|
||||
|
||||
#ifdef HAVE_GTK_MAC
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<AdditionalLibraryDirectories>$(ArchiveLibDir);$(DepsRoot)\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
|
||||
<AdditionalDependencies>$(DepLibs);$(ZoiteChatLib)common.lib;wbemuuid.lib;dwmapi.lib;pangoft2-1.0.lib;fontconfig.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||
<AdditionalDependencies>$(DepLibs);$(ZoiteChatLib)common.lib;wbemuuid.lib;dwmapi.lib;user32.lib;pangoft2-1.0.lib;fontconfig.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||
<EntryPointSymbol>mainCRTStartup</EntryPointSymbol>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
@@ -49,6 +49,7 @@ powershell "Get-Content -Encoding UTF8 '$(ZoiteChatLib)zoitechat.rc.utf8' | Out-
|
||||
</PreBuildEvent>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="idle.h" />
|
||||
<ClInclude Include="ascii.h" />
|
||||
<ClInclude Include="banlist.h" />
|
||||
<ClInclude Include="chanlist.h" />
|
||||
@@ -91,6 +92,7 @@ powershell "Get-Content -Encoding UTF8 '$(ZoiteChatLib)zoitechat.rc.utf8' | Out-
|
||||
<ClInclude Include="theme\theme-preferences.h" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="idle.c" />
|
||||
<ClCompile Include="ascii.c" />
|
||||
<ClCompile Include="banlist.c" />
|
||||
<ClCompile Include="chanlist.c" />
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="idle.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="ascii.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
@@ -137,6 +140,9 @@
|
||||
</ClInclude>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="idle.c">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="ascii.c">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
#include "maingui.h"
|
||||
#include "textgui.h"
|
||||
#include "fkeys.h"
|
||||
#ifdef __APPLE__
|
||||
#include "macos-shortcuts.h"
|
||||
#endif
|
||||
|
||||
#define ICON_FKEYS_NEW "document-new"
|
||||
#define ICON_FKEYS_DELETE "edit-delete"
|
||||
@@ -570,6 +573,12 @@ key_handle_key_press (GtkWidget *wid, GdkEventKey *evt, session *sess)
|
||||
list = g_slist_next (list);
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
/* Plugins and user-defined input shortcuts above retain priority. */
|
||||
if (macos_shortcuts_copy_chat (wid, evt, GTK_XTEXT (sess->gui->xtext)))
|
||||
return TRUE;
|
||||
#endif
|
||||
|
||||
switch (evt->keyval)
|
||||
{
|
||||
case GDK_KEY_space:
|
||||
@@ -1333,6 +1342,57 @@ key_save_kbs (void)
|
||||
#define KBSTATE_DT1 3
|
||||
#define KBSTATE_DT2 4
|
||||
|
||||
#ifdef __APPLE__
|
||||
static void
|
||||
key_macos_adjust_defaults (GSList *bindings)
|
||||
{
|
||||
GSList *item, *other;
|
||||
struct key_binding *kb, *existing;
|
||||
guint keyval;
|
||||
GdkModifierType mod;
|
||||
|
||||
for (item = bindings; item; item = item->next)
|
||||
{
|
||||
kb = item->data;
|
||||
keyval = kb->keyval;
|
||||
mod = kb->mod;
|
||||
if (!macos_shortcuts_remap_default (&keyval, &mod,
|
||||
key_actions[kb->action].name,
|
||||
kb->data1, kb->data2))
|
||||
continue;
|
||||
|
||||
/* Never let a migrated default shadow an existing user shortcut.
|
||||
* In that case leave the old assignment for the user to resolve. */
|
||||
for (other = bindings; other; other = other->next)
|
||||
{
|
||||
existing = other->data;
|
||||
if (existing != kb &&
|
||||
gdk_keyval_to_lower (existing->keyval) == gdk_keyval_to_lower (keyval) &&
|
||||
existing->mod == mod)
|
||||
break;
|
||||
}
|
||||
if (!other)
|
||||
{
|
||||
kb->keyval = keyval;
|
||||
kb->mod = mod;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gboolean
|
||||
key_is_bound (guint keyval, GdkModifierType mod)
|
||||
{
|
||||
GSList *item;
|
||||
for (item = keybind_list; item; item = item->next)
|
||||
{
|
||||
struct key_binding *kb = item->data;
|
||||
if (kb->keyval == keyval && kb->mod == mod)
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
#endif
|
||||
|
||||
#define STRIP_WHITESPACE \
|
||||
while (buf[0] == ' ' || buf[0] == '\t') \
|
||||
buf++; \
|
||||
@@ -1519,6 +1579,9 @@ key_load_kbs_from_buffer (char *ibuf, off_t size, GSList **out_list)
|
||||
}
|
||||
}
|
||||
g_free (ibuf);
|
||||
#ifdef __APPLE__
|
||||
key_macos_adjust_defaults (list);
|
||||
#endif
|
||||
*out_list = list;
|
||||
return 0;
|
||||
|
||||
|
||||
@@ -36,5 +36,8 @@ int key_action_insert (GtkWidget * wid, GdkEventKey * evt, char *d1, char *d2,
|
||||
session *sess);
|
||||
void key_check_replace_on_change (GtkEditable *editable, gpointer data);
|
||||
gboolean key_get_menu_accel (const char *name, guint *keyval, GdkModifierType *mod);
|
||||
#ifdef __APPLE__
|
||||
gboolean key_is_bound (guint keyval, GdkModifierType mod);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,98 @@
|
||||
/* ZoiteChat
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
#include <gtk/gtk.h>
|
||||
#include "../common/autoaway.h"
|
||||
#include "idle.h"
|
||||
|
||||
#ifdef WIN32
|
||||
#include <windows.h>
|
||||
#elif defined(__APPLE__)
|
||||
#include <CoreGraphics/CoreGraphics.h>
|
||||
#include <math.h>
|
||||
#elif defined(HAVE_XSS) && defined(GDK_WINDOWING_X11)
|
||||
#include <gdk/gdkx.h>
|
||||
#include <X11/extensions/scrnsaver.h>
|
||||
#endif
|
||||
|
||||
/* Do not count focus changes, redraws, timers or incoming IRC traffic. */
|
||||
static void
|
||||
idle_event (GdkEvent *event, gpointer data)
|
||||
{
|
||||
switch (event->type)
|
||||
{
|
||||
case GDK_KEY_PRESS:
|
||||
case GDK_BUTTON_PRESS:
|
||||
case GDK_SCROLL:
|
||||
case GDK_MOTION_NOTIFY:
|
||||
case GDK_TOUCH_BEGIN:
|
||||
case GDK_TOUCH_UPDATE:
|
||||
autoaway_activity ();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
gtk_main_do_event (event);
|
||||
}
|
||||
|
||||
void
|
||||
idle_init (void)
|
||||
{
|
||||
autoaway_activity ();
|
||||
/* Keep GTK's normal dispatch, including detached windows and dialogs. */
|
||||
gdk_event_handler_set (idle_event, NULL, NULL);
|
||||
}
|
||||
|
||||
/* Microseconds since desktop input, or -1 for the portable app fallback. */
|
||||
gint64
|
||||
fe_get_idle_time (void)
|
||||
{
|
||||
#ifdef WIN32
|
||||
LASTINPUTINFO input = {0};
|
||||
DWORD elapsed;
|
||||
|
||||
input.cbSize = sizeof input;
|
||||
if (!GetLastInputInfo (&input))
|
||||
return -1;
|
||||
|
||||
/* DWORD subtraction handles the 49-day GetTickCount wrap. Some injected
|
||||
* input has a slightly newer timestamp; do not treat it as weeks idle. */
|
||||
elapsed = GetTickCount () - input.dwTime;
|
||||
if ((gint32)elapsed < 0)
|
||||
elapsed = 0;
|
||||
return (gint64)elapsed * 1000;
|
||||
#elif defined(__APPLE__)
|
||||
CFTimeInterval seconds = CGEventSourceSecondsSinceLastEventType (
|
||||
kCGEventSourceStateCombinedSessionState, kCGAnyInputEventType);
|
||||
if (!isfinite (seconds) || seconds < 0 ||
|
||||
seconds >= (double)G_MAXINT64 / G_USEC_PER_SEC)
|
||||
return -1;
|
||||
return (gint64)(seconds * G_USEC_PER_SEC);
|
||||
#elif defined(HAVE_XSS) && defined(GDK_WINDOWING_X11)
|
||||
GdkDisplay *display = gdk_display_get_default ();
|
||||
XScreenSaverInfo info = {0};
|
||||
Display *xdisplay;
|
||||
int event_base, error_base, error;
|
||||
Status ok;
|
||||
const char *wayland = g_getenv ("WAYLAND_DISPLAY");
|
||||
|
||||
/* XWayland idle is not desktop-wide. Never pass a Wayland display to Xlib. */
|
||||
if (!display || !GDK_IS_X11_DISPLAY (display) || (wayland && *wayland))
|
||||
return -1;
|
||||
|
||||
xdisplay = gdk_x11_display_get_xdisplay (display);
|
||||
gdk_x11_display_error_trap_push (display);
|
||||
ok = XScreenSaverQueryExtension (xdisplay, &event_base, &error_base);
|
||||
if (ok)
|
||||
ok = XScreenSaverQueryInfo (xdisplay, DefaultRootWindow (xdisplay), &info);
|
||||
error = gdk_x11_display_error_trap_pop (display);
|
||||
if (!ok || error)
|
||||
return -1;
|
||||
return (gint64)info.idle * 1000;
|
||||
#else
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* ZoiteChat
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#ifndef ZOITECHAT_IDLE_H
|
||||
#define ZOITECHAT_IDLE_H
|
||||
|
||||
void idle_init (void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,391 @@
|
||||
/* macOS text editing shortcuts. GPL-2.0-or-later; see COPYING. */
|
||||
#include "macos-shortcuts.h"
|
||||
#include "config.h"
|
||||
#include "../common/zoitechat.h"
|
||||
#include "xtext.h"
|
||||
#include <gdk/gdkkeysyms.h>
|
||||
|
||||
static void (*preferences_callback) (void);
|
||||
|
||||
static void
|
||||
copy_text (GtkTextView *view)
|
||||
{
|
||||
GtkTextBuffer *buffer = gtk_text_view_get_buffer (view);
|
||||
GtkTextIter start, end;
|
||||
if (gtk_text_buffer_get_selection_bounds (buffer, &start, &end))
|
||||
{
|
||||
gchar *text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE);
|
||||
gtk_clipboard_set_text (gtk_widget_get_clipboard (GTK_WIDGET (view),
|
||||
GDK_SELECTION_CLIPBOARD), text, -1);
|
||||
g_free (text);
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
cut_text (GtkTextView *view)
|
||||
{
|
||||
copy_text (view);
|
||||
gtk_text_buffer_delete_selection (gtk_text_view_get_buffer (view), TRUE,
|
||||
gtk_text_view_get_editable (view));
|
||||
gtk_text_view_reset_im_context (view);
|
||||
gtk_text_view_scroll_mark_onscreen (view,
|
||||
gtk_text_buffer_get_insert (gtk_text_view_get_buffer (view)));
|
||||
}
|
||||
|
||||
typedef struct
|
||||
{
|
||||
GtkTextView *view;
|
||||
GtkTextBuffer *buffer;
|
||||
} TextPaste;
|
||||
|
||||
static void
|
||||
text_paste_received (GtkClipboard *clipboard, const gchar *text, gpointer userdata)
|
||||
{
|
||||
TextPaste *paste = userdata;
|
||||
GtkTextView *view = paste->view;
|
||||
GtkTextBuffer *buffer = paste->buffer;
|
||||
|
||||
/* Clipboard replies may arrive after a view was closed or switched to
|
||||
* another buffer. Retain the request's objects, but never edit a new one. */
|
||||
if (text && gtk_widget_get_realized (GTK_WIDGET (view)) &&
|
||||
gtk_text_view_get_buffer (view) == buffer)
|
||||
{
|
||||
gboolean editable = gtk_text_view_get_editable (view);
|
||||
gtk_text_buffer_begin_user_action (buffer);
|
||||
gtk_text_buffer_delete_selection (buffer, TRUE, editable);
|
||||
gtk_text_buffer_insert_interactive_at_cursor (buffer, text, -1, editable);
|
||||
gtk_text_buffer_end_user_action (buffer);
|
||||
gtk_text_view_reset_im_context (view);
|
||||
gtk_text_view_scroll_mark_onscreen (view, gtk_text_buffer_get_insert (buffer));
|
||||
g_signal_emit_by_name (buffer, "paste-done", clipboard);
|
||||
}
|
||||
g_object_unref (buffer);
|
||||
g_object_unref (view);
|
||||
g_free (paste);
|
||||
}
|
||||
|
||||
static void
|
||||
paste_text (GtkTextView *view)
|
||||
{
|
||||
TextPaste *paste = g_new (TextPaste, 1);
|
||||
paste->view = g_object_ref (view);
|
||||
paste->buffer = g_object_ref (gtk_text_view_get_buffer (view));
|
||||
/* ZoiteChat's editors consume text. GTK3's Quartz rich-text fallback
|
||||
* enumerates pasteboard UTIs without MIME names (a NULL atom assertion)
|
||||
* and its rich-text request callback is unimplemented. Request UTF-8
|
||||
* directly, retaining interactive selection and read-only behavior. */
|
||||
gtk_clipboard_request_text (gtk_widget_get_clipboard (GTK_WIDGET (view),
|
||||
GDK_SELECTION_CLIPBOARD), text_paste_received, paste);
|
||||
}
|
||||
|
||||
static void
|
||||
open_preferences (GtkWindow *window)
|
||||
{
|
||||
if (preferences_callback)
|
||||
preferences_callback ();
|
||||
}
|
||||
|
||||
static void
|
||||
minimize_window (GtkWindow *window)
|
||||
{
|
||||
gtk_window_iconify (window);
|
||||
}
|
||||
|
||||
void
|
||||
macos_shortcuts_minimize_active (GtkWindow *fallback)
|
||||
{
|
||||
GList *windows = gtk_window_list_toplevels ();
|
||||
GList *item;
|
||||
GtkWindow *target = fallback;
|
||||
|
||||
for (item = windows; item; item = item->next)
|
||||
{
|
||||
if (gtk_window_is_active (GTK_WINDOW (item->data)))
|
||||
{
|
||||
target = GTK_WINDOW (item->data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target)
|
||||
minimize_window (target);
|
||||
g_list_free (windows);
|
||||
}
|
||||
|
||||
static void
|
||||
add_movement (GtkBindingSet *bindings, guint keyval, GdkModifierType mod,
|
||||
GtkMovementStep step, gint count)
|
||||
{
|
||||
gtk_binding_entry_add_signal (bindings, keyval, mod, "move-cursor", 3,
|
||||
GTK_TYPE_MOVEMENT_STEP, step,
|
||||
G_TYPE_INT, count, G_TYPE_BOOLEAN, FALSE);
|
||||
gtk_binding_entry_add_signal (bindings, keyval, mod | GDK_SHIFT_MASK,
|
||||
"move-cursor", 3, GTK_TYPE_MOVEMENT_STEP, step,
|
||||
G_TYPE_INT, count, G_TYPE_BOOLEAN, TRUE);
|
||||
}
|
||||
|
||||
static void
|
||||
delete_text_view_backwards (GtkTextView *view, gboolean word)
|
||||
{
|
||||
GtkTextBuffer *buffer = gtk_text_view_get_buffer (view);
|
||||
GtkTextIter start, end;
|
||||
gboolean editable = gtk_text_view_get_editable (view);
|
||||
|
||||
/* GTK3 leaves GTK_DELETE_DISPLAY_LINE_ENDS unimplemented in TextView.
|
||||
* Preserve selection deletion and per-range editability instead of
|
||||
* moving the cursor first or deleting protected text unconditionally. */
|
||||
if (gtk_text_buffer_get_selection_bounds (buffer, &start, &end))
|
||||
gtk_text_buffer_delete_selection (buffer, TRUE, editable);
|
||||
else
|
||||
{
|
||||
gtk_text_buffer_get_iter_at_mark (buffer, &end, gtk_text_buffer_get_insert (buffer));
|
||||
start = end;
|
||||
if (word)
|
||||
gtk_text_iter_backward_word_start (&start);
|
||||
else
|
||||
gtk_text_view_backward_display_line_start (view, &start);
|
||||
gtk_text_buffer_begin_user_action (buffer);
|
||||
gtk_text_buffer_delete_interactive (buffer, &start, &end, editable);
|
||||
gtk_text_buffer_end_user_action (buffer);
|
||||
}
|
||||
gtk_text_view_reset_im_context (view);
|
||||
gtk_text_view_scroll_mark_onscreen (view, gtk_text_buffer_get_insert (buffer));
|
||||
}
|
||||
|
||||
static void
|
||||
delete_display_line_start (GtkTextView *view)
|
||||
{
|
||||
delete_text_view_backwards (view, FALSE);
|
||||
}
|
||||
|
||||
static void
|
||||
delete_word_start (GtkTextView *view)
|
||||
{
|
||||
delete_text_view_backwards (view, TRUE);
|
||||
}
|
||||
|
||||
static void
|
||||
install_text_bindings (GtkSettings *settings, GParamSpec *property,
|
||||
GtkCssProvider *provider)
|
||||
{
|
||||
GtkWidget *widgets[2];
|
||||
const char *selectors[] = {"entry", "textview"};
|
||||
const char *names[] = {"zoitechat-macos-entry", "zoitechat-macos-textview"};
|
||||
GdkScreen *screen = gdk_screen_get_default ();
|
||||
GString *css = g_string_new (NULL);
|
||||
guint i, j;
|
||||
|
||||
/* CSS key themes take precedence over class bindings. Put our small
|
||||
* sets first, retaining the theme's remaining bindings as fallbacks.
|
||||
* Remove our previous provider before reading a changed key theme. */
|
||||
g_object_ref (provider);
|
||||
gtk_style_context_remove_provider_for_screen (screen, GTK_STYLE_PROVIDER (provider));
|
||||
widgets[0] = gtk_entry_new ();
|
||||
widgets[1] = gtk_text_view_new ();
|
||||
for (i = 0; i < G_N_ELEMENTS (widgets); i++)
|
||||
{
|
||||
GPtrArray *fallbacks = NULL;
|
||||
GtkStyleContext *context = gtk_widget_get_style_context (widgets[i]);
|
||||
|
||||
g_object_ref_sink (widgets[i]);
|
||||
gtk_style_context_get (context, gtk_style_context_get_state (context),
|
||||
"-gtk-key-bindings", &fallbacks, NULL);
|
||||
g_string_append_printf (css, "%s { -gtk-key-bindings: %s", selectors[i], names[i]);
|
||||
for (j = 0; fallbacks && j < fallbacks->len; j++)
|
||||
{
|
||||
GtkBindingSet *binding = g_ptr_array_index (fallbacks, j);
|
||||
g_string_append_printf (css, ", %s", binding->set_name);
|
||||
}
|
||||
g_string_append (css, "; }\n");
|
||||
if (fallbacks)
|
||||
g_ptr_array_unref (fallbacks);
|
||||
gtk_widget_destroy (widgets[i]);
|
||||
g_object_unref (widgets[i]);
|
||||
}
|
||||
gtk_css_provider_load_from_data (provider, css->str, css->len, NULL);
|
||||
gtk_style_context_add_provider_for_screen (screen, GTK_STYLE_PROVIDER (provider),
|
||||
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
g_string_free (css, TRUE);
|
||||
g_object_unref (provider);
|
||||
}
|
||||
|
||||
void
|
||||
macos_shortcuts_init (void (*callback) (void))
|
||||
{
|
||||
GType types[] = {GTK_TYPE_ENTRY, GTK_TYPE_TEXT_VIEW};
|
||||
const char *names[] = {"zoitechat-macos-entry", "zoitechat-macos-textview"};
|
||||
GtkCssProvider *provider;
|
||||
gpointer window_class;
|
||||
GtkBindingSet *window_bindings;
|
||||
static gboolean initialized = FALSE;
|
||||
guint i;
|
||||
|
||||
preferences_callback = callback;
|
||||
if (initialized)
|
||||
return;
|
||||
initialized = TRUE;
|
||||
|
||||
/* Use Command for these three actions on macOS, preserving GTK's
|
||||
* selection, password-field and read-only checks. Entry subclasses
|
||||
* (including the spell-checking input and search fields) inherit these. */
|
||||
for (i = 0; i < G_N_ELEMENTS (types); i++)
|
||||
{
|
||||
gpointer klass = g_type_class_ref (types[i]);
|
||||
GtkBindingSet *bindings = gtk_binding_set_new (names[i]);
|
||||
|
||||
gtk_binding_entry_skip (bindings, GDK_KEY_c, GDK_CONTROL_MASK);
|
||||
gtk_binding_entry_skip (bindings, GDK_KEY_x, GDK_CONTROL_MASK);
|
||||
gtk_binding_entry_skip (bindings, GDK_KEY_v, GDK_CONTROL_MASK);
|
||||
if (types[i] == GTK_TYPE_TEXT_VIEW)
|
||||
{
|
||||
g_signal_new_class_handler ("zoitechat-macos-copy-text", types[i],
|
||||
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
|
||||
G_CALLBACK (copy_text), NULL, NULL, NULL,
|
||||
G_TYPE_NONE, 0);
|
||||
g_signal_new_class_handler ("zoitechat-macos-cut-text", types[i],
|
||||
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
|
||||
G_CALLBACK (cut_text), NULL, NULL, NULL,
|
||||
G_TYPE_NONE, 0);
|
||||
g_signal_new_class_handler ("zoitechat-macos-paste-text", types[i],
|
||||
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
|
||||
G_CALLBACK (paste_text), NULL, NULL, NULL,
|
||||
G_TYPE_NONE, 0);
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_v, GDK_META_MASK,
|
||||
"zoitechat-macos-paste-text", 0);
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_c, GDK_META_MASK,
|
||||
"zoitechat-macos-copy-text", 0);
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_x, GDK_META_MASK,
|
||||
"zoitechat-macos-cut-text", 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_c, GDK_META_MASK,
|
||||
"copy-clipboard", 0);
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_x, GDK_META_MASK,
|
||||
"cut-clipboard", 0);
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_v, GDK_META_MASK,
|
||||
"paste-clipboard", 0);
|
||||
}
|
||||
/* Two movements select the whole buffer without changing its text. */
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_a, GDK_META_MASK,
|
||||
"move-cursor", 3, GTK_TYPE_MOVEMENT_STEP,
|
||||
GTK_MOVEMENT_BUFFER_ENDS, G_TYPE_INT, -1,
|
||||
G_TYPE_BOOLEAN, FALSE);
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_a, GDK_META_MASK,
|
||||
"move-cursor", 3, GTK_TYPE_MOVEMENT_STEP,
|
||||
GTK_MOVEMENT_BUFFER_ENDS, G_TYPE_INT, 1,
|
||||
G_TYPE_BOOLEAN, TRUE);
|
||||
add_movement (bindings, GDK_KEY_Left, GDK_META_MASK,
|
||||
GTK_MOVEMENT_DISPLAY_LINE_ENDS, -1);
|
||||
add_movement (bindings, GDK_KEY_Right, GDK_META_MASK,
|
||||
GTK_MOVEMENT_DISPLAY_LINE_ENDS, 1);
|
||||
add_movement (bindings, GDK_KEY_Left, GDK_MOD1_MASK,
|
||||
GTK_MOVEMENT_WORDS, -1);
|
||||
add_movement (bindings, GDK_KEY_Right, GDK_MOD1_MASK,
|
||||
GTK_MOVEMENT_WORDS, 1);
|
||||
add_movement (bindings, GDK_KEY_Up, GDK_META_MASK,
|
||||
GTK_MOVEMENT_BUFFER_ENDS, -1);
|
||||
add_movement (bindings, GDK_KEY_Down, GDK_META_MASK,
|
||||
GTK_MOVEMENT_BUFFER_ENDS, 1);
|
||||
if (types[i] == GTK_TYPE_TEXT_VIEW)
|
||||
{
|
||||
g_signal_new_class_handler ("zoitechat-macos-delete-word-start", types[i],
|
||||
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
|
||||
G_CALLBACK (delete_word_start),
|
||||
NULL, NULL, NULL, G_TYPE_NONE, 0);
|
||||
g_signal_new_class_handler ("zoitechat-macos-delete-line-start", types[i],
|
||||
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
|
||||
G_CALLBACK (delete_display_line_start),
|
||||
NULL, NULL, NULL, G_TYPE_NONE, 0);
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_META_MASK,
|
||||
"zoitechat-macos-delete-line-start", 0);
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_MOD1_MASK,
|
||||
"zoitechat-macos-delete-word-start", 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_MOD1_MASK,
|
||||
"delete-from-cursor", 2, GTK_TYPE_DELETE_TYPE,
|
||||
GTK_DELETE_WORD_ENDS, G_TYPE_INT, -1);
|
||||
gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_META_MASK,
|
||||
"delete-from-cursor", 2, GTK_TYPE_DELETE_TYPE,
|
||||
GTK_DELETE_DISPLAY_LINE_ENDS, G_TYPE_INT, -1);
|
||||
}
|
||||
g_type_class_unref (klass);
|
||||
}
|
||||
provider = gtk_css_provider_new ();
|
||||
install_text_bindings (gtk_settings_get_default (), NULL, provider);
|
||||
g_signal_connect_object (gtk_settings_get_default (), "notify::gtk-key-theme-name",
|
||||
G_CALLBACK (install_text_bindings), provider, 0);
|
||||
g_signal_connect_object (gtk_settings_get_default (), "notify::gtk-theme-name",
|
||||
G_CALLBACK (install_text_bindings), provider, 0);
|
||||
g_object_unref (provider);
|
||||
|
||||
/* Window-level bindings also work in dialogs without a main menu bar.
|
||||
* The native menu uses the same actions when Quartz handles the key. */
|
||||
window_class = g_type_class_ref (GTK_TYPE_WINDOW);
|
||||
window_bindings = gtk_binding_set_by_class (window_class);
|
||||
g_signal_new_class_handler ("zoitechat-macos-minimize", GTK_TYPE_WINDOW,
|
||||
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
|
||||
G_CALLBACK (minimize_window), NULL, NULL, NULL,
|
||||
G_TYPE_NONE, 0);
|
||||
g_signal_new_class_handler ("zoitechat-macos-preferences", GTK_TYPE_WINDOW,
|
||||
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
|
||||
G_CALLBACK (open_preferences), NULL, NULL, NULL,
|
||||
G_TYPE_NONE, 0);
|
||||
gtk_binding_entry_add_signal (window_bindings, GDK_KEY_m, GDK_META_MASK,
|
||||
"zoitechat-macos-minimize", 0);
|
||||
gtk_binding_entry_add_signal (window_bindings, GDK_KEY_comma, GDK_META_MASK,
|
||||
"zoitechat-macos-preferences", 0);
|
||||
g_type_class_unref (window_class);
|
||||
}
|
||||
|
||||
gboolean
|
||||
macos_shortcuts_remap_default (guint *keyval, GdkModifierType *mod,
|
||||
const char *action, const char *data1,
|
||||
const char *data2)
|
||||
{
|
||||
/* Only exact legacy defaults qualify. Custom actions, parameters and
|
||||
* alternate shortcuts must survive loading a configuration unchanged. */
|
||||
if (data2 && *data2)
|
||||
return FALSE;
|
||||
if (*keyval == GDK_KEY_Tab && *mod == GDK_META_MASK &&
|
||||
!g_strcmp0 (action, "Complete nick/command") && !g_strcmp0 (data1, "Up"))
|
||||
{
|
||||
*mod = GDK_CONTROL_MASK;
|
||||
return TRUE;
|
||||
}
|
||||
if ((!data1 || !*data1) && *mod == GDK_MOD1_MASK &&
|
||||
((*keyval == GDK_KEY_Left && !g_strcmp0 (action, "Move front tab left")) ||
|
||||
(*keyval == GDK_KEY_Right && !g_strcmp0 (action, "Move front tab right"))))
|
||||
{
|
||||
*mod |= GDK_META_MASK;
|
||||
return TRUE;
|
||||
}
|
||||
if (*keyval == GDK_KEY_m && *mod == GDK_META_MASK &&
|
||||
!g_strcmp0 (action, "Menu Shortcut") && !g_strcmp0 (data1, "reset-marker"))
|
||||
{
|
||||
*keyval = GDK_KEY_R;
|
||||
*mod |= GDK_SHIFT_MASK;
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
macos_shortcuts_copy_chat (GtkWidget *entry, GdkEventKey *event, GtkXText *xtext)
|
||||
{
|
||||
gint start, end;
|
||||
GdkModifierType modifiers = event->state &
|
||||
(GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_MOD1_MASK |
|
||||
GDK_META_MASK | GDK_SUPER_MASK | GDK_HYPER_MASK);
|
||||
|
||||
/* XText leaves keyboard focus in the message input. Prefer an input
|
||||
* selection; otherwise Command+C copies the selected chat text. Leave
|
||||
* Command+Shift+C and all other shortcuts to their existing handlers. */
|
||||
if (gdk_keyval_to_lower (event->keyval) != GDK_KEY_c || modifiers != GDK_META_MASK ||
|
||||
gtk_editable_get_selection_bounds (GTK_EDITABLE (entry), &start, &end))
|
||||
return FALSE;
|
||||
|
||||
gtk_xtext_copy_selection (xtext);
|
||||
return TRUE;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* macOS text editing shortcuts. GPL-2.0-or-later; see COPYING. */
|
||||
#ifndef ZOITECHAT_MACOS_SHORTCUTS_H
|
||||
#define ZOITECHAT_MACOS_SHORTCUTS_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
typedef struct _GtkXText GtkXText;
|
||||
|
||||
void macos_shortcuts_init (void (*open_preferences) (void));
|
||||
void macos_shortcuts_minimize_active (GtkWindow *fallback);
|
||||
gboolean macos_shortcuts_remap_default (guint *keyval, GdkModifierType *mod,
|
||||
const char *action, const char *data1,
|
||||
const char *data2);
|
||||
gboolean macos_shortcuts_copy_chat (GtkWidget *entry, GdkEventKey *event,
|
||||
GtkXText *xtext);
|
||||
|
||||
#endif
|
||||
+59
-6
@@ -51,6 +51,9 @@
|
||||
#include "chanlist.h"
|
||||
#include "editlist.h"
|
||||
#include "fkeys.h"
|
||||
#ifdef __APPLE__
|
||||
#include "macos-shortcuts.h"
|
||||
#endif
|
||||
#include "gtkutil.h"
|
||||
#include "maingui.h"
|
||||
#include "notifygui.h"
|
||||
@@ -1322,6 +1325,17 @@ menu_settings (GtkWidget * wid, gpointer none)
|
||||
setup_open ();
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
static void
|
||||
menu_macos_minimize (GtkWidget *widget, gpointer userdata)
|
||||
{
|
||||
GtkWindow *fallback = NULL;
|
||||
if (current_sess && current_sess->gui && current_sess->gui->window)
|
||||
fallback = GTK_WINDOW (current_sess->gui->window);
|
||||
macos_shortcuts_minimize_active (fallback);
|
||||
}
|
||||
#endif
|
||||
|
||||
static void
|
||||
menu_usermenu (void)
|
||||
{
|
||||
@@ -2069,7 +2083,7 @@ static struct mymenu mymenu[] = {
|
||||
{N_("_Copy Selection"), menu_copy_selection, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("C_lear Text"), menu_flushbuffer, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("Save Text" ELLIPSIS), menu_savebuffer, 0, M_MENUITEM, 0, 0, 1},
|
||||
#define SEARCH_OFFSET (70)
|
||||
#define SEARCH_OFFSET (71)
|
||||
{N_("Search"), 0, 0, M_MENUSUB, 0, 0, 1},
|
||||
{N_("Search Text" ELLIPSIS), menu_search, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("Search Next" ), menu_search_next, 0, M_MENUITEM, 0, 0, 1},
|
||||
@@ -2106,11 +2120,11 @@ menu_get_key_action_name (int index)
|
||||
return "fullscreen-toggle";
|
||||
case AWAY_OFFSET:
|
||||
return "away-toggle";
|
||||
case 65:
|
||||
return "reset-marker";
|
||||
case 66:
|
||||
return "move-marker";
|
||||
return "reset-marker";
|
||||
case 67:
|
||||
return "move-marker";
|
||||
case 68:
|
||||
return "copy-selection";
|
||||
case SEARCH_OFFSET + 1:
|
||||
return "search-text";
|
||||
@@ -2118,7 +2132,7 @@ menu_get_key_action_name (int index)
|
||||
return "search-next";
|
||||
case SEARCH_OFFSET + 3:
|
||||
return "search-previous";
|
||||
case 75:
|
||||
case 77:
|
||||
return "contents";
|
||||
}
|
||||
|
||||
@@ -2142,6 +2156,20 @@ menu_add_keybinding_accel (GtkWidget *item, GtkAccelGroup *accel_group, const ch
|
||||
g_object_set_data (G_OBJECT (item), "zc-key-accel-mod", GUINT_TO_POINTER (mod));
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
static void
|
||||
menu_add_macos_accel (GtkWidget *item, GtkAccelGroup *accel_group, guint keyval)
|
||||
{
|
||||
g_object_set_data (G_OBJECT (item), "zc-macos-default-key", GUINT_TO_POINTER (keyval));
|
||||
if (!accel_group || key_is_bound (keyval, GDK_META_MASK))
|
||||
return;
|
||||
gtk_widget_add_accelerator (item, "activate", accel_group, keyval,
|
||||
GDK_META_MASK, GTK_ACCEL_VISIBLE);
|
||||
g_object_set_data (G_OBJECT (item), "zc-key-accel-key", GUINT_TO_POINTER (keyval));
|
||||
g_object_set_data (G_OBJECT (item), "zc-key-accel-mod", GUINT_TO_POINTER (GDK_META_MASK));
|
||||
}
|
||||
#endif
|
||||
|
||||
static void
|
||||
menu_refresh_keybinding_accels (GtkWidget *widget, gpointer data)
|
||||
{
|
||||
@@ -2164,6 +2192,12 @@ menu_refresh_keybinding_accels (GtkWidget *widget, gpointer data)
|
||||
}
|
||||
|
||||
name = g_object_get_data (G_OBJECT (widget), "zc-key-action");
|
||||
#ifdef __APPLE__
|
||||
keyval = GPOINTER_TO_UINT (g_object_get_data (G_OBJECT (widget), "zc-macos-default-key"));
|
||||
if (keyval)
|
||||
menu_add_macos_accel (widget, accel_group, keyval);
|
||||
else
|
||||
#endif
|
||||
menu_add_keybinding_accel (widget, accel_group, name);
|
||||
|
||||
submenu = gtk_menu_item_get_submenu (GTK_MENU_ITEM (widget));
|
||||
@@ -2217,7 +2251,12 @@ menu_key_action (const char *name, guint keyval, GdkModifierType state)
|
||||
menu_close (NULL, NULL);
|
||||
else if (!strcmp (name, "quit"))
|
||||
{
|
||||
if (!prefs.hex_gui_ctrlq_quit && keyval == GDK_KEY_q && (state & (GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_MOD1_MASK)) == STATE_CTRL)
|
||||
if (!prefs.hex_gui_ctrlq_quit && keyval == GDK_KEY_q &&
|
||||
(state & (GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_MOD1_MASK
|
||||
#ifdef __APPLE__
|
||||
| GDK_META_MASK
|
||||
#endif
|
||||
)) == STATE_CTRL)
|
||||
return FALSE;
|
||||
menu_quit (NULL, NULL);
|
||||
}
|
||||
@@ -2841,6 +2880,16 @@ menu_create_main (void *accel_group, int bar, int away, int toplevel,
|
||||
#endif
|
||||
gtk_menu_shell_append (GTK_MENU_SHELL (menu_bar), menu_item);
|
||||
gtk_widget_show (menu_item);
|
||||
#ifdef __APPLE__
|
||||
if (bar && !strcmp (mymenu[i].text, "_Window"))
|
||||
{
|
||||
GtkWidget *minimize = gtk_menu_item_new_with_mnemonic (_("_Minimize"));
|
||||
menu_add_macos_accel (minimize, accel_group, GDK_KEY_m);
|
||||
g_signal_connect (minimize, "activate", G_CALLBACK (menu_macos_minimize), NULL);
|
||||
gtk_menu_shell_append (GTK_MENU_SHELL (menu), minimize);
|
||||
gtk_widget_show (minimize);
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
|
||||
case M_MENUPIX:
|
||||
@@ -2854,6 +2903,10 @@ menu_create_main (void *accel_group, int bar, int away, int toplevel,
|
||||
case M_MENUITEM:
|
||||
item = gtk_menu_item_new_with_mnemonic (_(mymenu[i].text));
|
||||
normalitem:
|
||||
#ifdef __APPLE__
|
||||
if (bar && mymenu[i].callback == menu_settings)
|
||||
menu_add_macos_accel (item, accel_group, GDK_KEY_comma);
|
||||
#endif
|
||||
g_object_set_data (G_OBJECT (item), "zc-key-action", (gpointer) menu_get_key_action_name (i));
|
||||
menu_add_keybinding_accel (item, accel_group, menu_get_key_action_name (i));
|
||||
if (mymenu[i].key != 0 && !(mymenu[i].id == MENU_ID_QUIT && !prefs.hex_gui_ctrlq_quit))
|
||||
|
||||
+24
-2
@@ -25,6 +25,7 @@ zoitechat_gtk_sources = [
|
||||
'gtkutil.c',
|
||||
'icon-resolver.c',
|
||||
'ignoregui.c',
|
||||
'idle.c',
|
||||
'inline-image.c',
|
||||
'joind.c',
|
||||
'menu.c',
|
||||
@@ -45,6 +46,7 @@ zoitechat_gtk_sources = [
|
||||
]
|
||||
|
||||
zoitechat_gtk_cflags = []
|
||||
idle_deps = []
|
||||
|
||||
zoitechat_gtk_deps = [
|
||||
zoitechat_common_dep,
|
||||
@@ -93,9 +95,17 @@ if host_machine.system() != 'windows'
|
||||
zoitechat_gtk_deps += gdk_x11_dep
|
||||
endif
|
||||
|
||||
xss_dep = dependency('xscrnsaver', required: false)
|
||||
if gdk_x11_dep.found() and xss_dep.found()
|
||||
zoitechat_gtk_deps += xss_dep
|
||||
idle_deps += [gdk_x11_dep, xss_dep]
|
||||
zoitechat_gtk_cflags += '-DHAVE_XSS'
|
||||
endif
|
||||
|
||||
x11_dep = dependency('x11', required: false)
|
||||
if x11_dep.found()
|
||||
zoitechat_gtk_deps += x11_dep
|
||||
idle_deps += x11_dep
|
||||
endif
|
||||
endif
|
||||
|
||||
@@ -105,12 +115,16 @@ zoitechat_gtk_objcflags = []
|
||||
if host_machine.system() == 'windows'
|
||||
zoitechat_gtk_sources += 'notifications/notification-windows.c'
|
||||
zoitechat_gtk_deps += cc.find_library('dwmapi', required: true)
|
||||
idle_deps += cc.find_library('user32', required: true)
|
||||
zoitechat_gtk_deps += idle_deps
|
||||
zoitechat_theme_deps += cc.find_library('dwmapi', required: true)
|
||||
|
||||
elif host_machine.system() == 'darwin'
|
||||
add_languages('objc', required: true)
|
||||
zoitechat_gtk_sources += 'macos-shortcuts.c'
|
||||
zoitechat_gtk_sources += 'notifications/notification-osx.m'
|
||||
zoitechat_gtk_deps += dependency('appleframeworks', modules: ['Cocoa', 'UserNotifications'])
|
||||
zoitechat_gtk_deps += dependency('appleframeworks', modules: ['Cocoa', 'UserNotifications', 'CoreGraphics'])
|
||||
idle_deps += dependency('appleframeworks', modules: ['CoreGraphics'])
|
||||
zoitechat_gtk_objcflags += '-fobjc-arc'
|
||||
else
|
||||
zoitechat_gtk_sources += 'notifications/notification-freedesktop.c'
|
||||
@@ -296,8 +310,16 @@ test('Emoji Font Tests', emoji_font_tests,
|
||||
)
|
||||
|
||||
xtext_tests = executable('xtext_tests',
|
||||
['tests/test-xtext.c', '../common/hexcolor.c'],
|
||||
['tests/test-xtext.c', 'macos-shortcuts.c', '../common/hexcolor.c'],
|
||||
include_directories: [config_h_include],
|
||||
dependencies: [gtk_dep],
|
||||
)
|
||||
test('XText Tests', xtext_tests, protocol: 'tap', timeout: 120)
|
||||
|
||||
idle_tests = executable('idle_tests',
|
||||
'tests/test-idle.c',
|
||||
include_directories: [config_h_include],
|
||||
dependencies: [gtk_dep] + idle_deps,
|
||||
c_args: zoitechat_gtk_cflags,
|
||||
)
|
||||
test('Idle Backend Tests', idle_tests, protocol: 'tap', timeout: 30)
|
||||
@@ -558,6 +558,10 @@ static const setting general_settings[] =
|
||||
{ST_ENTRY, N_("Away:"), P_OFFSETNL(hex_away_reason), 0, 0, sizeof prefs.hex_away_reason},
|
||||
|
||||
{ST_HEADER, N_("Away"),0,0,0},
|
||||
{ST_NUMBER, N_("Autoaway after:"), P_OFFINTNL(hex_away_auto_timeout), N_("Set to 0 to disable automatic away."), (const char **)N_("minutes (0 = off)."), 1440},
|
||||
{ST_ENTRY, N_("Autoaway message:"), P_OFFSETNL(hex_away_auto_reason), 0, 0, sizeof prefs.hex_away_auto_reason},
|
||||
{ST_TOGGLE, N_("Use desktop activity when available"), P_OFFINTNL(hex_away_auto_system), N_("Use desktop input on Windows, macOS and X11. Otherwise, use keyboard and mouse activity in ZoiteChat."), 0, 0},
|
||||
{ST_LABEL, N_("On Wayland, autoaway uses activity in ZoiteChat.\nOnly automatically set away states are cleared when activity resumes.")},
|
||||
{ST_TOGGLE, N_("Show away once"), P_OFFINTNL(hex_away_show_once), N_("Show identical away messages only once."), 0, 0},
|
||||
{ST_TOGGLE, N_("Automatically unmark away"), P_OFFINTNL(hex_away_auto_unmark), N_("Unmark yourself as away before sending messages."), 0, 0},
|
||||
|
||||
@@ -567,7 +571,11 @@ static const setting general_settings[] =
|
||||
{ST_TOGGLE, N_("Hide join and part messages"), P_OFFINTNL(hex_irc_conf_mode), N_("Hide channel join/part messages by default."), 0, 0},
|
||||
{ST_TOGGLE, N_("Hide nick change messages"), P_OFFINTNL(hex_irc_hide_nickchange), 0, 0, 0},
|
||||
{ST_TOGGLE, N_("Hide hostmasks in join and part messages"), P_OFFINTNL(hex_irc_hide_join_part_hostmask), 0, 0, 0},
|
||||
#ifdef __APPLE__
|
||||
{ST_TOGGLE, N_("Enable Command+Q to quit"), P_OFFINTNL(hex_gui_ctrlq_quit), 0, 0, 0},
|
||||
#else
|
||||
{ST_TOGGLE, N_("Enable Ctrl+Q to quit"), P_OFFINTNL(hex_gui_ctrlq_quit), 0, 0, 0},
|
||||
#endif
|
||||
|
||||
{ST_END, 0, 0, 0, 0, 0}
|
||||
};
|
||||
|
||||
@@ -23,5 +23,6 @@
|
||||
#include "theme/theme-manager.h"
|
||||
|
||||
void setup_apply_real (const ThemeChangedEvent *event);
|
||||
void setup_open (void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,85 @@
|
||||
/* ZoiteChat
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
#include "../idle.h"
|
||||
|
||||
static int activity_count, dispatch_count;
|
||||
static void test_gtk_dispatch (GdkEvent *event);
|
||||
|
||||
void
|
||||
autoaway_activity (void)
|
||||
{
|
||||
activity_count++;
|
||||
}
|
||||
|
||||
/* Exercise the real handler without delivering synthetic input to a widget. */
|
||||
#define gtk_main_do_event test_gtk_dispatch
|
||||
#include "../idle.c"
|
||||
#undef gtk_main_do_event
|
||||
|
||||
static void
|
||||
test_gtk_dispatch (GdkEvent *event)
|
||||
{
|
||||
dispatch_count++;
|
||||
}
|
||||
|
||||
static void
|
||||
test_user_input_and_dispatch (void)
|
||||
{
|
||||
GdkEvent event = {0};
|
||||
GdkEventType input[] = {GDK_KEY_PRESS, GDK_BUTTON_PRESS, GDK_SCROLL,
|
||||
GDK_MOTION_NOTIFY, GDK_TOUCH_BEGIN, GDK_TOUCH_UPDATE};
|
||||
GdkEventType other[] = {GDK_FOCUS_CHANGE, GDK_CONFIGURE, GDK_WINDOW_STATE,
|
||||
GDK_PROPERTY_NOTIFY, GDK_VISIBILITY_NOTIFY};
|
||||
unsigned int i;
|
||||
|
||||
activity_count = dispatch_count = 0;
|
||||
for (i = 0; i < G_N_ELEMENTS (input); i++)
|
||||
{
|
||||
event.type = input[i];
|
||||
idle_event (&event, NULL);
|
||||
}
|
||||
g_assert_cmpint (activity_count, ==, G_N_ELEMENTS (input));
|
||||
for (i = 0; i < G_N_ELEMENTS (other); i++)
|
||||
{
|
||||
event.type = other[i];
|
||||
idle_event (&event, NULL);
|
||||
}
|
||||
g_assert_cmpint (activity_count, ==, G_N_ELEMENTS (input));
|
||||
g_assert_cmpint (dispatch_count, ==, G_N_ELEMENTS (input) + G_N_ELEMENTS (other));
|
||||
}
|
||||
|
||||
static void
|
||||
test_native_idle (void)
|
||||
{
|
||||
gint64 idle = fe_get_idle_time ();
|
||||
g_assert_cmpint (idle, >=, -1);
|
||||
#if defined(HAVE_XSS) && defined(GDK_WINDOWING_X11)
|
||||
if (gdk_display_get_default () && GDK_IS_X11_DISPLAY (gdk_display_get_default ()))
|
||||
{
|
||||
/* XWayland must use the app fallback, even if XSS is compiled in. */
|
||||
char *saved = g_strdup (g_getenv ("WAYLAND_DISPLAY"));
|
||||
g_unsetenv ("WAYLAND_DISPLAY");
|
||||
g_assert_cmpint (fe_get_idle_time (), >=, 0);
|
||||
g_setenv ("WAYLAND_DISPLAY", "wayland-test", TRUE);
|
||||
g_assert_cmpint (fe_get_idle_time (), ==, -1);
|
||||
if (saved)
|
||||
g_setenv ("WAYLAND_DISPLAY", saved, TRUE);
|
||||
else
|
||||
g_unsetenv ("WAYLAND_DISPLAY");
|
||||
g_free (saved);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
int
|
||||
main (int argc, char **argv)
|
||||
{
|
||||
g_test_init (&argc, &argv, NULL);
|
||||
gtk_init_check (&argc, &argv);
|
||||
g_test_add_func ("/idle/user-input-and-dispatch", test_user_input_and_dispatch);
|
||||
g_test_add_func ("/idle/native-backend", test_native_idle);
|
||||
return g_test_run ();
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* adding test-only symbols to the widget's public API. Application services
|
||||
* are stubbed; GTK, Pango, Cairo and the widget itself are exercised normally. */
|
||||
#include "../xtext.c"
|
||||
#include "../macos-shortcuts.h"
|
||||
#include <glib/gstdio.h>
|
||||
|
||||
struct zoitechatprefs prefs;
|
||||
@@ -252,6 +253,470 @@ static void test_selection_and_clipboard (Fixture *f, gconstpointer data)
|
||||
g_assert_false (gtk_xtext_is_selecting (f->xtext));
|
||||
}
|
||||
|
||||
static void assert_clipboard (GtkWidget *widget, const char *expected)
|
||||
{
|
||||
char *text = gtk_clipboard_wait_for_text (
|
||||
gtk_widget_get_clipboard (widget, GDK_SELECTION_CLIPBOARD));
|
||||
g_assert_cmpstr (text, ==, expected);
|
||||
g_free (text);
|
||||
}
|
||||
|
||||
static GtkWidget *shortcut_window (GtkWidget *widget)
|
||||
{
|
||||
GtkWidget *window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
|
||||
/* Give mapped editor fixtures a stable allocation before the first draw. */
|
||||
gtk_window_set_default_size (GTK_WINDOW (window), 640, 240);
|
||||
gtk_container_set_border_width (GTK_CONTAINER (window), 16);
|
||||
if (GTK_IS_ENTRY (widget))
|
||||
{
|
||||
gtk_widget_set_size_request (widget, 320, 40);
|
||||
gtk_widget_set_valign (widget, GTK_ALIGN_CENTER);
|
||||
}
|
||||
gtk_container_add (GTK_CONTAINER (window), widget);
|
||||
gtk_widget_show_all (window);
|
||||
drain_events ();
|
||||
return window;
|
||||
}
|
||||
|
||||
static void test_macos_entry_shortcuts (gconstpointer data)
|
||||
{
|
||||
gboolean search = GPOINTER_TO_INT (data);
|
||||
GtkWidget *entry = g_object_ref_sink (search ? gtk_search_entry_new () : gtk_entry_new ());
|
||||
GtkWidget *window;
|
||||
GtkClipboard *clipboard;
|
||||
gint64 deadline;
|
||||
|
||||
if (search)
|
||||
{
|
||||
/* Inheritance and clipboard actions do not require drawing. Quartz
|
||||
* SearchEntry icon rendering can emit an unrelated fatal warning.
|
||||
* Keep this variant attached to a screen but unmapped; the ordinary
|
||||
* Entry variant below still exercises a mapped, editable widget. */
|
||||
window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
|
||||
gtk_container_add (GTK_CONTAINER (window), entry);
|
||||
g_assert_true (gtk_widget_has_screen (entry));
|
||||
g_assert_false (gtk_widget_get_mapped (entry));
|
||||
}
|
||||
else
|
||||
{
|
||||
window = shortcut_window (entry);
|
||||
g_assert_true (gtk_widget_get_mapped (entry));
|
||||
}
|
||||
clipboard = gtk_widget_get_clipboard (entry, GDK_SELECTION_CLIPBOARD);
|
||||
|
||||
gtk_entry_set_text (GTK_ENTRY (entry), "hello 猫");
|
||||
gtk_editable_select_region (GTK_EDITABLE (entry), 6, 7);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_META_MASK));
|
||||
assert_clipboard (entry, "猫");
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫");
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_META_MASK));
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello ");
|
||||
assert_clipboard (entry, "猫");
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_META_MASK));
|
||||
deadline = g_get_monotonic_time () + G_TIME_SPAN_SECOND * 2;
|
||||
while (strcmp (gtk_entry_get_text (GTK_ENTRY (entry)), "hello 猫") &&
|
||||
g_get_monotonic_time () < deadline)
|
||||
{
|
||||
drain_events ();
|
||||
g_usleep (1000);
|
||||
}
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫");
|
||||
|
||||
/* The old Control actions must no longer perform clipboard editing. */
|
||||
gtk_editable_select_region (GTK_EDITABLE (entry), 0, -1);
|
||||
gtk_clipboard_set_text (clipboard, "sentinel", -1);
|
||||
g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_CONTROL_MASK));
|
||||
g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_CONTROL_MASK));
|
||||
g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_CONTROL_MASK));
|
||||
assert_clipboard (entry, "sentinel");
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫");
|
||||
|
||||
/* Password fields are ordinary entries, not icon-bearing search fields. */
|
||||
if (!search)
|
||||
{
|
||||
gtk_entry_set_visibility (GTK_ENTRY (entry), FALSE);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_META_MASK));
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_META_MASK));
|
||||
assert_clipboard (entry, "sentinel");
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫");
|
||||
gtk_entry_set_visibility (GTK_ENTRY (entry), TRUE);
|
||||
}
|
||||
gtk_editable_set_editable (GTK_EDITABLE (entry), FALSE);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_META_MASK));
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_META_MASK));
|
||||
drain_events ();
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫");
|
||||
gtk_widget_destroy (window);
|
||||
g_object_unref (entry);
|
||||
}
|
||||
|
||||
static void unexpected_textview_clipboard_action (GtkTextView *view, gpointer data)
|
||||
{
|
||||
guint *count = data;
|
||||
GSignalInvocationHint *hint = g_signal_get_invocation_hint (view);
|
||||
(*count)++;
|
||||
/* Fail on incorrect dispatch, before GTK's Quartz rich-text path runs. */
|
||||
g_signal_stop_emission (view, hint->signal_id, hint->detail);
|
||||
}
|
||||
|
||||
static void test_macos_textview_shortcuts (void)
|
||||
{
|
||||
GtkWidget *view = g_object_ref_sink (gtk_text_view_new ());
|
||||
GtkWidget *window = shortcut_window (view);
|
||||
GtkTextBuffer *buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view));
|
||||
GtkTextIter start, end;
|
||||
gint64 deadline;
|
||||
guint default_actions = 0;
|
||||
|
||||
g_signal_connect (view, "copy-clipboard", G_CALLBACK (unexpected_textview_clipboard_action), &default_actions);
|
||||
g_signal_connect (view, "cut-clipboard", G_CALLBACK (unexpected_textview_clipboard_action), &default_actions);
|
||||
g_signal_connect (view, "paste-clipboard", G_CALLBACK (unexpected_textview_clipboard_action), &default_actions);
|
||||
|
||||
gtk_text_buffer_set_text (buffer, "two\nlines", -1);
|
||||
g_test_message ("Select multiline text");
|
||||
gtk_text_buffer_get_bounds (buffer, &start, &end);
|
||||
gtk_text_buffer_select_range (buffer, &start, &end);
|
||||
g_test_message ("Copy multiline text");
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_c, GDK_META_MASK));
|
||||
g_assert_cmpuint (default_actions, ==, 0);
|
||||
assert_clipboard (view, "two\nlines");
|
||||
g_test_message ("Cut multiline text");
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_x, GDK_META_MASK));
|
||||
g_assert_cmpuint (default_actions, ==, 0);
|
||||
g_assert_cmpint (gtk_text_buffer_get_char_count (buffer), ==, 0);
|
||||
assert_clipboard (view, "two\nlines");
|
||||
g_test_message ("Paste multiline text");
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_META_MASK));
|
||||
g_assert_cmpuint (default_actions, ==, 0);
|
||||
deadline = g_get_monotonic_time () + G_TIME_SPAN_SECOND * 2;
|
||||
while (gtk_text_buffer_get_char_count (buffer) == 0 &&
|
||||
g_get_monotonic_time () < deadline)
|
||||
{
|
||||
drain_events ();
|
||||
g_usleep (1000);
|
||||
}
|
||||
g_assert_cmpint (gtk_text_buffer_get_char_count (buffer), ==, 9);
|
||||
gtk_text_buffer_get_bounds (buffer, &start, &end);
|
||||
{
|
||||
char *text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE);
|
||||
g_assert_cmpstr (text, ==, "two\nlines");
|
||||
g_free (text);
|
||||
}
|
||||
g_assert_false (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_c, GDK_CONTROL_MASK));
|
||||
g_assert_false (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_x, GDK_CONTROL_MASK));
|
||||
g_assert_false (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_CONTROL_MASK));
|
||||
gtk_widget_destroy (window);
|
||||
g_object_unref (view);
|
||||
}
|
||||
|
||||
static void test_macos_chat_copy (Fixture *f, gconstpointer data)
|
||||
{
|
||||
GtkWidget *entry = g_object_ref_sink (gtk_entry_new ());
|
||||
GtkWidget *window = shortcut_window (entry);
|
||||
GdkEventKey event = {0};
|
||||
|
||||
append (f, "chat selection 猫");
|
||||
select_entries (f, f->xtext->buffer->text_first, f->xtext->buffer->text_last);
|
||||
event.keyval = GDK_KEY_C; /* Caps Lock must not disable chat copying. */
|
||||
event.state = GDK_META_MASK | GDK_LOCK_MASK | GDK_MOD2_MASK;
|
||||
g_assert_true (macos_shortcuts_copy_chat (entry, &event, f->xtext));
|
||||
assert_clipboard (entry, "chat selection 猫");
|
||||
|
||||
/* Selected input takes priority over an older selection in scrollback. */
|
||||
gtk_entry_set_text (GTK_ENTRY (entry), "input");
|
||||
gtk_editable_select_region (GTK_EDITABLE (entry), 0, -1);
|
||||
g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext));
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_META_MASK));
|
||||
assert_clipboard (entry, "input");
|
||||
gtk_editable_select_region (GTK_EDITABLE (entry), 0, 0);
|
||||
event.state = GDK_META_MASK | GDK_SHIFT_MASK;
|
||||
g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext));
|
||||
event.state = GDK_CONTROL_MASK;
|
||||
g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext));
|
||||
event.state = GDK_META_MASK;
|
||||
event.keyval = GDK_KEY_x;
|
||||
g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext));
|
||||
assert_clipboard (entry, "input");
|
||||
gtk_widget_destroy (window);
|
||||
g_object_unref (entry);
|
||||
}
|
||||
|
||||
static void assert_entry_selection (GtkWidget *entry, gint start, gint end)
|
||||
{
|
||||
gint actual_start, actual_end;
|
||||
g_assert_true (gtk_editable_get_selection_bounds (GTK_EDITABLE (entry),
|
||||
&actual_start, &actual_end));
|
||||
g_assert_cmpint (actual_start, ==, start);
|
||||
g_assert_cmpint (actual_end, ==, end);
|
||||
}
|
||||
|
||||
static void test_macos_key_theme_fallback (void)
|
||||
{
|
||||
GtkWidget *entry = gtk_entry_new ();
|
||||
GtkWidget *window = shortcut_window (entry);
|
||||
gint start, end;
|
||||
|
||||
/* A key-theme reload must keep our overrides first and retain the
|
||||
* Mac theme's Emacs-style Control+A/E navigation for other keys. */
|
||||
g_object_notify (G_OBJECT (gtk_settings_get_default ()), "gtk-key-theme-name");
|
||||
g_object_notify (G_OBJECT (gtk_settings_get_default ()), "gtk-theme-name");
|
||||
gtk_entry_set_text (GTK_ENTRY (entry), "one two");
|
||||
gtk_editable_set_position (GTK_EDITABLE (entry), -1);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_a, GDK_CONTROL_MASK));
|
||||
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 0);
|
||||
g_assert_false (gtk_editable_get_selection_bounds (GTK_EDITABLE (entry), &start, &end));
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_e, GDK_CONTROL_MASK));
|
||||
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 7);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_a, GDK_META_MASK));
|
||||
assert_entry_selection (entry, 0, 7);
|
||||
g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_CONTROL_MASK));
|
||||
gtk_widget_destroy (window);
|
||||
}
|
||||
|
||||
static void test_macos_entry_editing (void)
|
||||
{
|
||||
GtkWidget *entry = gtk_entry_new ();
|
||||
GtkWidget *window = shortcut_window (entry);
|
||||
|
||||
gtk_entry_set_text (GTK_ENTRY (entry), "one two three");
|
||||
gtk_editable_set_position (GTK_EDITABLE (entry), 5);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_a, GDK_META_MASK));
|
||||
assert_entry_selection (entry, 0, 13);
|
||||
gtk_editable_set_position (GTK_EDITABLE (entry), 5);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_META_MASK));
|
||||
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 0);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Right, GDK_META_MASK));
|
||||
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 13);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_META_MASK | GDK_SHIFT_MASK));
|
||||
assert_entry_selection (entry, 0, 13);
|
||||
gtk_editable_set_position (GTK_EDITABLE (entry), 13);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_MOD1_MASK));
|
||||
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 8);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_MOD1_MASK | GDK_SHIFT_MASK));
|
||||
assert_entry_selection (entry, 4, 8);
|
||||
gtk_editable_set_position (GTK_EDITABLE (entry), 0);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Right, GDK_MOD1_MASK));
|
||||
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 3);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Right, GDK_MOD1_MASK | GDK_SHIFT_MASK));
|
||||
assert_entry_selection (entry, 3, 7);
|
||||
gtk_editable_set_position (GTK_EDITABLE (entry), 13);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_MOD1_MASK));
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "one two ");
|
||||
gtk_entry_set_text (GTK_ENTRY (entry), "猫 abc def");
|
||||
gtk_editable_set_position (GTK_EDITABLE (entry), 6);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_META_MASK));
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "def");
|
||||
gtk_editable_select_region (GTK_EDITABLE (entry), 0, 2);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_META_MASK));
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "f");
|
||||
gtk_editable_set_editable (GTK_EDITABLE (entry), FALSE);
|
||||
gtk_editable_set_position (GTK_EDITABLE (entry), -1);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_MOD1_MASK));
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_META_MASK));
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "f");
|
||||
gtk_widget_destroy (window);
|
||||
}
|
||||
|
||||
static void assert_buffer_text (GtkTextBuffer *buffer, const char *expected)
|
||||
{
|
||||
GtkTextIter start, end;
|
||||
char *text;
|
||||
gtk_text_buffer_get_bounds (buffer, &start, &end);
|
||||
text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE);
|
||||
g_assert_cmpstr (text, ==, expected);
|
||||
g_free (text);
|
||||
}
|
||||
|
||||
static void paste_completed (GtkTextBuffer *buffer, GtkClipboard *clipboard, guint *count)
|
||||
{
|
||||
(*count)++;
|
||||
}
|
||||
|
||||
static void wait_for_paste (guint *count, guint expected)
|
||||
{
|
||||
gint64 deadline = g_get_monotonic_time () + 2 * G_TIME_SPAN_SECOND;
|
||||
while (*count < expected && g_get_monotonic_time () < deadline)
|
||||
{
|
||||
drain_events ();
|
||||
g_usleep (1000);
|
||||
}
|
||||
g_assert_cmpuint (*count, ==, expected);
|
||||
}
|
||||
|
||||
static void text_with_native_type (GtkClipboard *clipboard, GtkSelectionData *selection,
|
||||
guint info, gpointer data)
|
||||
{
|
||||
if (info == 0)
|
||||
gtk_selection_data_set_text (selection, data, -1);
|
||||
}
|
||||
|
||||
static void test_macos_multiline_paste (void)
|
||||
{
|
||||
GtkWidget *view = gtk_text_view_new ();
|
||||
GtkWidget *window = shortcut_window (view);
|
||||
GtkTextBuffer *buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view));
|
||||
GtkClipboard *clipboard = gtk_widget_get_clipboard (view, GDK_SELECTION_CLIPBOARD);
|
||||
GtkTextIter start, end;
|
||||
guint pasted = 0;
|
||||
GtkTargetEntry targets[] = {
|
||||
{(gchar *) "UTF8_STRING", 0, 0},
|
||||
/* This base UTI has no preferred MIME name on Quartz. A text paste
|
||||
* must not enumerate/convert it through GTK's rich-text fallback. */
|
||||
{(gchar *) "public.item", 0, 1}
|
||||
};
|
||||
|
||||
g_signal_connect (buffer, "paste-done", G_CALLBACK (paste_completed), &pasted);
|
||||
gtk_text_buffer_set_text (buffer, "before after", -1);
|
||||
gtk_text_buffer_get_iter_at_offset (buffer, &start, 7);
|
||||
gtk_text_buffer_get_end_iter (buffer, &end);
|
||||
gtk_text_buffer_select_range (buffer, &start, &end);
|
||||
g_assert_true (gtk_clipboard_set_with_data (clipboard, targets, G_N_ELEMENTS (targets),
|
||||
text_with_native_type, NULL, (gpointer) "猫\npasted"));
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_META_MASK));
|
||||
wait_for_paste (&pasted, 1);
|
||||
assert_buffer_text (buffer, "before 猫\npasted");
|
||||
|
||||
gtk_text_view_set_editable (GTK_TEXT_VIEW (view), FALSE);
|
||||
gtk_text_buffer_get_bounds (buffer, &start, &end);
|
||||
gtk_text_buffer_select_range (buffer, &start, &end);
|
||||
gtk_clipboard_set_text (clipboard, "blocked", -1);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_META_MASK));
|
||||
wait_for_paste (&pasted, 2);
|
||||
assert_buffer_text (buffer, "before 猫\npasted");
|
||||
g_assert_false (gtk_text_view_get_editable (GTK_TEXT_VIEW (view)));
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_x, GDK_META_MASK));
|
||||
assert_buffer_text (buffer, "before 猫\npasted");
|
||||
assert_clipboard (view, "before 猫\npasted");
|
||||
gtk_clipboard_clear (clipboard);
|
||||
gtk_widget_destroy (window);
|
||||
}
|
||||
|
||||
static void test_macos_multiline_editing (void)
|
||||
{
|
||||
GtkWidget *view = gtk_text_view_new ();
|
||||
GtkWidget *window = shortcut_window (view);
|
||||
GtkTextBuffer *buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view));
|
||||
GtkTextIter start, end;
|
||||
|
||||
gtk_text_buffer_set_text (buffer, "first\nsecond", -1);
|
||||
gtk_text_buffer_get_iter_at_offset (buffer, &start, 9);
|
||||
gtk_text_buffer_place_cursor (buffer, &start);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Left, GDK_META_MASK));
|
||||
gtk_text_buffer_get_iter_at_mark (buffer, &start, gtk_text_buffer_get_insert (buffer));
|
||||
g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 6);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Right, GDK_META_MASK | GDK_SHIFT_MASK));
|
||||
g_assert_true (gtk_text_buffer_get_selection_bounds (buffer, &start, &end));
|
||||
g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 6);
|
||||
g_assert_cmpint (gtk_text_iter_get_offset (&end), ==, 12);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_a, GDK_META_MASK));
|
||||
g_assert_true (gtk_text_buffer_get_selection_bounds (buffer, &start, &end));
|
||||
g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 0);
|
||||
g_assert_cmpint (gtk_text_iter_get_offset (&end), ==, 12);
|
||||
gtk_text_buffer_get_iter_at_offset (buffer, &start, 9);
|
||||
gtk_text_buffer_place_cursor (buffer, &start);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK));
|
||||
assert_buffer_text (buffer, "first\nond");
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Down, GDK_META_MASK));
|
||||
gtk_text_buffer_get_iter_at_mark (buffer, &start, gtk_text_buffer_get_insert (buffer));
|
||||
g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 9);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Up, GDK_META_MASK | GDK_SHIFT_MASK));
|
||||
g_assert_true (gtk_text_buffer_get_selection_bounds (buffer, &start, &end));
|
||||
g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 0);
|
||||
g_assert_cmpint (gtk_text_iter_get_offset (&end), ==, 9);
|
||||
/* An existing selection is deleted without removing the rest of its line. */
|
||||
gtk_text_buffer_get_iter_at_offset (buffer, &start, 7);
|
||||
gtk_text_buffer_get_iter_at_offset (buffer, &end, 8);
|
||||
gtk_text_buffer_select_range (buffer, &start, &end);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK));
|
||||
assert_buffer_text (buffer, "first\nod");
|
||||
gtk_text_view_set_editable (GTK_TEXT_VIEW (view), FALSE);
|
||||
gtk_text_buffer_get_end_iter (buffer, &end);
|
||||
gtk_text_buffer_place_cursor (buffer, &end);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK));
|
||||
assert_buffer_text (buffer, "first\nod");
|
||||
gtk_text_view_set_editable (GTK_TEXT_VIEW (view), TRUE);
|
||||
{
|
||||
GtkTextTag *protected = gtk_text_buffer_create_tag (buffer, "protected", "editable", FALSE, NULL);
|
||||
gtk_text_buffer_get_iter_at_offset (buffer, &start, 6);
|
||||
gtk_text_buffer_get_iter_at_offset (buffer, &end, 7);
|
||||
gtk_text_buffer_apply_tag (buffer, protected, &start, &end);
|
||||
gtk_text_buffer_get_end_iter (buffer, &end);
|
||||
gtk_text_buffer_place_cursor (buffer, &end);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK));
|
||||
assert_buffer_text (buffer, "first\no");
|
||||
}
|
||||
gtk_text_buffer_set_text (buffer, "one two\nthree four", -1);
|
||||
gtk_text_buffer_get_end_iter (buffer, &end);
|
||||
gtk_text_buffer_place_cursor (buffer, &end);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_MOD1_MASK));
|
||||
assert_buffer_text (buffer, "one two\nthree ");
|
||||
gtk_text_buffer_get_iter_at_offset (buffer, &start, 4);
|
||||
gtk_text_buffer_get_iter_at_offset (buffer, &end, 7);
|
||||
gtk_text_buffer_select_range (buffer, &start, &end);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_MOD1_MASK));
|
||||
assert_buffer_text (buffer, "one \nthree ");
|
||||
gtk_widget_destroy (window);
|
||||
}
|
||||
|
||||
static guint preferences_opened;
|
||||
|
||||
static void test_open_preferences (void)
|
||||
{
|
||||
preferences_opened++;
|
||||
}
|
||||
|
||||
static void count_minimize (GtkWindow *window, guint *count)
|
||||
{
|
||||
(*count)++;
|
||||
/* Check action routing without requiring an X11 window manager. */
|
||||
g_signal_stop_emission_by_name (window, "zoitechat-macos-minimize");
|
||||
}
|
||||
|
||||
static void test_macos_window_shortcuts (void)
|
||||
{
|
||||
GtkWidget *window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
|
||||
guint minimized = 0;
|
||||
preferences_opened = 0;
|
||||
g_signal_connect (window, "zoitechat-macos-minimize", G_CALLBACK (count_minimize), &minimized);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (window), GDK_KEY_comma, GDK_META_MASK));
|
||||
g_assert_cmpuint (preferences_opened, ==, 1);
|
||||
g_assert_true (gtk_bindings_activate (G_OBJECT (window), GDK_KEY_m, GDK_META_MASK));
|
||||
g_assert_cmpuint (minimized, ==, 1);
|
||||
gtk_widget_destroy (window);
|
||||
}
|
||||
|
||||
static void test_macos_legacy_defaults (void)
|
||||
{
|
||||
const struct {
|
||||
guint key, new_key;
|
||||
GdkModifierType mod, new_mod;
|
||||
const char *action, *data1, *data2;
|
||||
gboolean remapped;
|
||||
} cases[] = {
|
||||
{GDK_KEY_Tab, GDK_KEY_Tab, GDK_META_MASK, GDK_CONTROL_MASK, "Complete nick/command", "Up", NULL, TRUE},
|
||||
{GDK_KEY_Left, GDK_KEY_Left, GDK_MOD1_MASK, GDK_META_MASK | GDK_MOD1_MASK, "Move front tab left", NULL, NULL, TRUE},
|
||||
{GDK_KEY_Right, GDK_KEY_Right, GDK_MOD1_MASK, GDK_META_MASK | GDK_MOD1_MASK, "Move front tab right", "", "", TRUE},
|
||||
{GDK_KEY_m, GDK_KEY_R, GDK_META_MASK, GDK_META_MASK | GDK_SHIFT_MASK, "Menu Shortcut", "reset-marker", NULL, TRUE},
|
||||
{GDK_KEY_m, GDK_KEY_m, GDK_META_MASK, GDK_META_MASK, "Menu Shortcut", "search-text", NULL, FALSE},
|
||||
{GDK_KEY_m, GDK_KEY_m, GDK_CONTROL_MASK, GDK_CONTROL_MASK, "Menu Shortcut", "reset-marker", NULL, FALSE},
|
||||
{GDK_KEY_Left, GDK_KEY_Left, GDK_MOD1_MASK, GDK_MOD1_MASK, "Move front tab left", "custom", NULL, FALSE},
|
||||
{GDK_KEY_Tab, GDK_KEY_Tab, GDK_META_MASK, GDK_META_MASK, "Complete nick/command", "Up", "custom", FALSE},
|
||||
};
|
||||
guint i;
|
||||
for (i = 0; i < G_N_ELEMENTS (cases); i++)
|
||||
{
|
||||
guint key = cases[i].key;
|
||||
GdkModifierType mod = cases[i].mod;
|
||||
g_assert_cmpint (macos_shortcuts_remap_default (&key, &mod, cases[i].action,
|
||||
cases[i].data1, cases[i].data2), ==, cases[i].remapped);
|
||||
g_assert_cmpuint (key, ==, cases[i].new_key);
|
||||
g_assert_cmpuint (mod, ==, cases[i].new_mod);
|
||||
/* Saved migrated shortcuts must be stable on subsequent launches. */
|
||||
g_assert_false (macos_shortcuts_remap_default (&key, &mod, cases[i].action,
|
||||
cases[i].data1, cases[i].data2));
|
||||
}
|
||||
}
|
||||
|
||||
static void test_search (Fixture *f, gconstpointer data)
|
||||
{
|
||||
GError *error = NULL;
|
||||
@@ -567,7 +1032,44 @@ int main (int argc, char **argv)
|
||||
g_test_message ("GTK display unavailable; run under Xvfb for widget coverage");
|
||||
return 77;
|
||||
}
|
||||
g_object_set (gtk_settings_get_default (), "gtk-enable-animations", FALSE, NULL);
|
||||
/* Quartz installs this CSS key theme automatically. Exercise its higher
|
||||
* priority bindings on Linux too, rather than only testing GTK classes. */
|
||||
g_object_set (gtk_settings_get_default (), "gtk-key-theme-name", "Mac", NULL);
|
||||
if (!gtk_binding_set_find ("gtk-mac-cmd-c"))
|
||||
{
|
||||
/* Some Linux packages omit GTK's Mac theme. Reproduce the upstream
|
||||
* CSS clipboard bindings so this regression is still tested there. */
|
||||
GtkCssProvider *keys = gtk_css_provider_new ();
|
||||
g_test_message ("Using portable Mac key-theme fixture");
|
||||
gtk_css_provider_load_from_data (keys,
|
||||
"@binding-set zoitechat-test-mac-clipboard {"
|
||||
" bind '<meta>c' { 'copy-clipboard' () };"
|
||||
" bind '<meta>x' { 'cut-clipboard' () };"
|
||||
" bind '<meta>v' { 'paste-clipboard' () };"
|
||||
" bind '<ctrl>a' { 'move-cursor' (paragraph-ends, -1, 0) };"
|
||||
" bind '<ctrl>e' { 'move-cursor' (paragraph-ends, 1, 0) }; }"
|
||||
"entry, textview { -gtk-key-bindings: zoitechat-test-mac-clipboard; }",
|
||||
-1, NULL);
|
||||
gtk_style_context_add_provider_for_screen (gdk_screen_get_default (),
|
||||
GTK_STYLE_PROVIDER (keys), GTK_STYLE_PROVIDER_PRIORITY_SETTINGS);
|
||||
g_object_unref (keys);
|
||||
}
|
||||
else
|
||||
g_test_message ("Using GTK's installed Mac key theme");
|
||||
prefs.hex_gui_mouse_scroll_speed = 10;
|
||||
/* Exercise the macOS implementation with real GTK widgets on every OS.
|
||||
* Production only installs these bindings on macOS. */
|
||||
macos_shortcuts_init (test_open_preferences);
|
||||
g_test_add_data_func ("/macos-shortcuts/entry", GINT_TO_POINTER (FALSE), test_macos_entry_shortcuts);
|
||||
g_test_add_data_func ("/macos-shortcuts/search-entry", GINT_TO_POINTER (TRUE), test_macos_entry_shortcuts);
|
||||
g_test_add_func ("/macos-shortcuts/textview", test_macos_textview_shortcuts);
|
||||
g_test_add_func ("/macos-shortcuts/key-theme-fallback", test_macos_key_theme_fallback);
|
||||
g_test_add_func ("/macos-shortcuts/entry-editing", test_macos_entry_editing);
|
||||
g_test_add_func ("/macos-shortcuts/multiline-editing", test_macos_multiline_editing);
|
||||
g_test_add_func ("/macos-shortcuts/multiline-paste", test_macos_multiline_paste);
|
||||
g_test_add_func ("/macos-shortcuts/window", test_macos_window_shortcuts);
|
||||
g_test_add_func ("/macos-shortcuts/legacy-defaults", test_macos_legacy_defaults);
|
||||
#define ADD(name, function) g_test_add ("/xtext/" name, Fixture, NULL, fixture_setup, function, fixture_teardown)
|
||||
ADD ("append/empty-bounded", test_empty_and_bounded_append);
|
||||
ADD ("append/utf8-limits", test_utf8_limits);
|
||||
@@ -577,6 +1079,7 @@ int main (int argc, char **argv)
|
||||
ADD ("layout/wide-metrics", test_wide_metrics);
|
||||
ADD ("render/formatting-timestamps", test_formatting);
|
||||
ADD ("selection/clipboard", test_selection_and_clipboard);
|
||||
ADD ("selection/macos-command-copy", test_macos_chat_copy);
|
||||
ADD ("search/navigation-regex-clear", test_search);
|
||||
ADD ("search/follow", test_follow_search);
|
||||
ADD ("search/unicode-offsets", test_search_unicode_offsets);
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
#endif
|
||||
|
||||
|
||||
gint64
|
||||
fe_get_idle_time (void)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int done = FALSE;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" ToolsVersion="12.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Release|x64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<ProjectGuid>{EE388A27-6992-43C0-BA9B-61D8968D42E3}</ProjectGuid>
|
||||
<SolutionDir>$(MSBuildThisFileDirectory)</SolutionDir>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Label="Configuration">
|
||||
<PlatformToolset>v142</PlatformToolset>
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<Import Project="zoitechat.props" />
|
||||
<PropertyGroup>
|
||||
<TargetName>autoaway-tests</TargetName>
|
||||
<OutDir>$(ZoiteChatBuild)\$(ZoiteChatPlatform)\tests\</OutDir>
|
||||
<IntDir>$(ZoiteChatObj)autoaway-tests\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile>
|
||||
<PreprocessorDefinitions>WIN32;_WIN64;_CONSOLE;$(OwnFlags);%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<AdditionalIncludeDirectories>$(ZoiteChatLib);$(DepsRoot)\include;$(OpenSslInclude);$(Glib);$(Gtk);%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<AdditionalLibraryDirectories>$(DepsRoot)\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
|
||||
<AdditionalDependencies>gio-2.0.lib;gobject-2.0.lib;glib-2.0.lib;$(IntlLib);%(AdditionalDependencies)</AdditionalDependencies>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="..\src\common\autoaway.c" />
|
||||
<ClCompile Include="..\src\common\tests\test-autoaway.c" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
@@ -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="%1"" />
|
||||
</uap3:Extension>
|
||||
|
||||
<uap3:Extension Category="windows.fileTypeAssociation">
|
||||
<uap3:FileTypeAssociation
|
||||
Name="zoitechattheme"
|
||||
Parameters=""%1"">
|
||||
<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>
|
||||
@@ -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"
|
||||
@@ -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 = "ADMIN" OR VCREDIST_X64_INSTALLED = "#1""
|
||||
Message="Microsoft Visual C++ 2015-2022 x64 Redistributable is required before installing ZoiteChat." />
|
||||
<Launch
|
||||
Condition="Installed OR ACTION = "ADMIN" OR VersionNT64 > 601 OR (VersionNT64 = 601 AND ServicePackLevel >= 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=""[INSTALLFOLDER]zoitechat.exe" --url="%1"" />
|
||||
|
||||
<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=""[INSTALLFOLDER]zoitechat.exe" "%1"" />
|
||||
</Component>
|
||||
</Fragment>
|
||||
</Wix>
|
||||
Reference in new issue
Block a user