mirror of
https://github.com/ZoiteChat/zoitechat.git
synced 2026-10-25 13:55:38 +00:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8edd669067 | ||
|
|
81f4f591f5 | ||
|
|
8e7885c7f6 | ||
|
|
03c4e872c0 | ||
|
|
9b65e5be73 | ||
|
|
9547dd3711 | ||
|
|
abf49ce756 | ||
|
|
6bf2f34fca | ||
|
|
2ea74498ed | ||
|
|
7004c679f2 | ||
|
|
c9de3f2ce0 | ||
|
|
f77d4f6d59 | ||
|
|
89b9a32168 | ||
|
|
357b766369 | ||
|
|
45a8ba18a6 | ||
|
|
0e0f8f72b2 | ||
|
|
dc5f8f7631 | ||
|
|
3a32b1eae6 | ||
|
|
7bf7e31459 | ||
|
|
c931867c4a | ||
|
|
a2c0eb646f | ||
|
|
8a03585611 | ||
|
|
74d9e35b37 | ||
|
|
1ae2d41378 | ||
|
|
070603b0fe | ||
|
|
9e3daf494f | ||
|
|
8f47b5cedf | ||
|
|
646ad029f0 | ||
|
|
d47963247d | ||
|
|
4f7add046c | ||
|
|
61ec9af5e3 | ||
|
|
412dc92b10 | ||
|
|
04dab3dcd6 | ||
|
|
c51f885253 | ||
|
|
aa7e20fee9 | ||
|
|
480b99b1e7 | ||
|
|
0a4367b1da | ||
|
|
39daca9d22 | ||
|
|
d614e25792 | ||
|
|
30209d0c99 | ||
|
|
e1c44feb01 | ||
|
|
5af8f4ede1 |
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
name: macOS build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sign_and_notarize:
|
||||
description: 'Sign with Developer ID and notarize using release credentials'
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ['v*']
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'plugins/**'
|
||||
- 'data/**'
|
||||
- 'tools/**'
|
||||
- 'po/**'
|
||||
- 'meson*'
|
||||
- 'VERSION'
|
||||
- 'packaging/macos/**'
|
||||
- '.github/workflows/macos-build.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'plugins/**'
|
||||
- 'data/**'
|
||||
- 'tools/**'
|
||||
- 'po/**'
|
||||
- 'meson*'
|
||||
- 'VERSION'
|
||||
- 'packaging/macos/**'
|
||||
- '.github/workflows/macos-build.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: >-
|
||||
github.event_name == 'pull_request' ||
|
||||
(github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) ||
|
||||
(github.event_name == 'workflow_dispatch' && !inputs.sign_and_notarize)
|
||||
name: Native ${{ matrix.arch }} bundle
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
arch: x86_64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install native dependencies
|
||||
run: brew bundle --file packaging/macos/Brewfile
|
||||
- name: Test packaging logic
|
||||
run: python3 -m unittest discover -s packaging/macos/tests -v
|
||||
- name: Build and test application
|
||||
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
|
||||
with:
|
||||
name: ZoiteChat-macos-${{ matrix.arch }}
|
||||
path: dist/ZoiteChat-${{ matrix.arch }}.zip
|
||||
if-no-files-found: error
|
||||
- name: Upload build logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: ZoiteChat-macos-${{ matrix.arch }}-logs
|
||||
path: build-macos/meson-logs/
|
||||
if-no-files-found: ignore
|
||||
|
||||
release:
|
||||
name: Signed and notarized ${{ matrix.arch }} release
|
||||
if: >-
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.sign_and_notarize && github.ref == 'refs/heads/master')
|
||||
environment: macos-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
arch: x86_64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate release settings before building
|
||||
env:
|
||||
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
MACOS_SIGNING_IDENTITY: ${{ vars.MACOS_SIGNING_IDENTITY || secrets.MACOS_SIGNING_IDENTITY }}
|
||||
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
|
||||
run: python3 packaging/macos/release.py --check-config
|
||||
- name: Install native dependencies
|
||||
run: brew bundle --file packaging/macos/Brewfile
|
||||
- name: Test packaging and release logic
|
||||
run: python3 -m unittest discover -s packaging/macos/tests -v
|
||||
- name: Build and test application
|
||||
run: python3 packaging/macos/build.py --build-number '${{ github.run_number }}'
|
||||
- name: Verify relocated startup
|
||||
run: python3 packaging/macos/smoke.py dist/ZoiteChat.app
|
||||
- name: Sign, notarize, staple and verify release
|
||||
env:
|
||||
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
MACOS_SIGNING_IDENTITY: ${{ vars.MACOS_SIGNING_IDENTITY || secrets.MACOS_SIGNING_IDENTITY }}
|
||||
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
|
||||
run: >-
|
||||
python3 packaging/macos/release.py
|
||||
--app dist/ZoiteChat.app
|
||||
--output dist/ZoiteChat-${{ matrix.arch }}-signed-notarized.zip
|
||||
--diagnostics dist/notarization
|
||||
--notary-timeout 60m
|
||||
- name: Upload signed and notarized application
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: ZoiteChat-macos-${{ matrix.arch }}-signed-notarized
|
||||
path: dist/ZoiteChat-${{ matrix.arch }}-signed-notarized.zip
|
||||
if-no-files-found: error
|
||||
- name: Upload notarization diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: ZoiteChat-macos-${{ matrix.arch }}-notarization-log
|
||||
path: dist/notarization/*.json
|
||||
if-no-files-found: ignore
|
||||
- name: Upload build logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: ZoiteChat-macos-${{ matrix.arch }}-release-build-logs
|
||||
path: build-macos/meson-logs/
|
||||
if-no-files-found: ignore
|
||||
@@ -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 }}
|
||||
@@ -222,7 +222,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 +236,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,428 @@
|
||||
name: Windows MSI/MSIX Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- 'release/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
windows_msi_build:
|
||||
runs-on: windows-2022
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [x64]
|
||||
arch: [x64]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@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 emoji catalog, font coverage and picker
|
||||
shell: cmd
|
||||
run: |
|
||||
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat"
|
||||
set "PATH=%CD%\..\zoitechat-build\x64\rel;%PATH%"
|
||||
set "GDK_PIXBUF_MODULE_FILE=%CD%\..\zoitechat-build\x64\rel\lib\gdk-pixbuf-2.0\2.10.0\loaders.cache"
|
||||
set "ZOITECHAT_EMOJI_FONT=%CD%\..\zoitechat-build\x64\rel\share\fonts\zoitechat\NotoColorEmoji.ttf"
|
||||
fc /b data\fonts\NotoColorEmoji.ttf "%ZOITECHAT_EMOJI_FONT%"
|
||||
if errorlevel 1 exit /b 1
|
||||
for %%T in (data font ui) do (
|
||||
msbuild win32\emoji-tests.vcxproj /m /verbosity:minimal /p:Configuration=Release /p:Platform=x64 /p:EmojiTestKind=%%T
|
||||
if errorlevel 1 exit /b 1
|
||||
..\zoitechat-build\x64\tests\emoji-%%T-tests.exe --tap
|
||||
if errorlevel 1 exit /b 1
|
||||
)
|
||||
|
||||
- name: Install WiX Toolset
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# Keep this pinned. WiX v6 introduced OSMF terms and WiX v7 adds
|
||||
# explicit EULA acceptance; do not silently accept new terms in CI.
|
||||
dotnet tool install --tool-path .wix wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to install WiX Toolset 5.0.2' }
|
||||
& .\.wix\wix.exe --version
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX Toolset could not be executed' }
|
||||
|
||||
- name: Build MSI
|
||||
id: build_msi
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$rawVersion = (Get-Content VERSION -Raw).Trim()
|
||||
if ($rawVersion -notmatch '^(\d+)\.(\d+)\.(\d+)') {
|
||||
throw "VERSION '$rawVersion' does not begin with an MSI-compatible major.minor.patch version"
|
||||
}
|
||||
|
||||
# Keep the full development version in the filename, while MSI's
|
||||
# ProductVersion is numeric (e.g. 2.20.0-dev -> 2.20.0).
|
||||
$msiVersion = "$($Matches[1]).$($Matches[2]).$($Matches[3])"
|
||||
$safeVersion = $rawVersion -replace '[^0-9A-Za-z._-]', '-'
|
||||
$sourceDir = (Resolve-Path "..\zoitechat-build\${{ matrix.platform }}\rel").Path
|
||||
$outputPath = Join-Path $PWD "ZoiteChat-${safeVersion}_${{ matrix.arch }}.msi"
|
||||
|
||||
if (-not (Test-Path (Join-Path $sourceDir 'zoitechat.exe'))) {
|
||||
throw "Staged ZoiteChat executable is missing from $sourceDir"
|
||||
}
|
||||
|
||||
& .\.wix\wix.exe build win32\wix\zoitechat.wxs `
|
||||
-arch x64 `
|
||||
-d "SourceDir=$sourceDir" `
|
||||
-d "ProductVersion=$msiVersion" `
|
||||
-out $outputPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX MSI build failed' }
|
||||
|
||||
if (-not (Test-Path $outputPath)) {
|
||||
throw "Expected MSI was not created: $outputPath"
|
||||
}
|
||||
|
||||
"path=$outputPath" >> $env:GITHUB_OUTPUT
|
||||
"filename=$(Split-Path $outputPath -Leaf)" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Test MSI administrative extraction
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = '${{ steps.build_msi.outputs.path }}'
|
||||
$adminImage = Join-Path $PWD 'msi-admin-image'
|
||||
New-Item -ItemType Directory -Path $adminImage -Force | Out-Null
|
||||
|
||||
$arguments = "/a `"$msi`" /qn TARGETDIR=`"$adminImage`""
|
||||
$process = Start-Process -FilePath msiexec.exe -ArgumentList $arguments -Wait -PassThru
|
||||
if ($process.ExitCode -ne 0) {
|
||||
throw "MSI administrative extraction failed with exit code $($process.ExitCode)"
|
||||
}
|
||||
|
||||
foreach ($requiredFile in @(
|
||||
'zoitechat.exe',
|
||||
'hclua.dll',
|
||||
'hcupd.dll',
|
||||
'WinSparkle.dll',
|
||||
'hcchecksum.dll',
|
||||
'hcexec.dll',
|
||||
'hcfishlim.dll',
|
||||
'hcsysinfo.dll',
|
||||
'system.png',
|
||||
'hcperl.dll',
|
||||
'hcpython3.dll',
|
||||
'hcpython38.dll',
|
||||
'NotoColorEmoji.ttf',
|
||||
'OFL.txt'
|
||||
)) {
|
||||
$match = Get-ChildItem -Path $adminImage -Filter $requiredFile -File -Recurse | Select-Object -First 1
|
||||
if ($null -eq $match) {
|
||||
throw "Administrative image does not contain required normal-install file: $requiredFile"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredPattern in @('_cffi_backend.cp314*.pyd', '_cffi_backend.cp38*.pyd')) {
|
||||
$match = Get-ChildItem -Path $adminImage -Filter $requiredPattern -File -Recurse | Select-Object -First 1
|
||||
if ($null -eq $match) {
|
||||
throw "Administrative image does not contain required Python support file: $requiredPattern"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredPythonFile in @('zoitechat.py', '_zoitechat.py', 'hexchat.py', 'xchat.py')) {
|
||||
$match = Get-ChildItem -Path $adminImage -Filter $requiredPythonFile -File -Recurse |
|
||||
Where-Object { $_.Directory.Name -eq 'python' } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $match) {
|
||||
throw "Administrative image does not contain required Python bridge file: $requiredPythonFile"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($excludedFile in @(
|
||||
'portable-mode',
|
||||
'zoitechat-text.exe'
|
||||
)) {
|
||||
$match = Get-ChildItem -Path $adminImage -Filter $excludedFile -File -Recurse | Select-Object -First 1
|
||||
if ($null -ne $match) {
|
||||
throw "MSI incorrectly contains Inno custom/portable-only file: $excludedFile"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Build MSIX
|
||||
id: build_msix
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$rawVersion = (Get-Content VERSION -Raw).Trim()
|
||||
if ($rawVersion -notmatch '^(\d+)\.(\d+)\.(\d+)') {
|
||||
throw "VERSION '$rawVersion' does not begin with a Store-compatible major.minor.build version"
|
||||
}
|
||||
|
||||
$versionParts = @(
|
||||
[int]$Matches[1],
|
||||
[int]$Matches[2],
|
||||
[int]$Matches[3]
|
||||
)
|
||||
if ($versionParts[0] -eq 0) {
|
||||
throw 'The Microsoft Store requires a non-zero major package version.'
|
||||
}
|
||||
foreach ($part in $versionParts) {
|
||||
if ($part -lt 0 -or $part -gt 65535) {
|
||||
throw "MSIX version component '$part' is outside the Store-supported 0-65535 range."
|
||||
}
|
||||
}
|
||||
|
||||
# Partner Center reserves the fourth field as 0. A -dev version
|
||||
# is packaged as a valid 2.20.0.0, without changing VERSION.
|
||||
$packageVersion = "$($versionParts[0]).$($versionParts[1]).$($versionParts[2]).0"
|
||||
$safeVersion = $rawVersion -replace '[^0-9A-Za-z._-]', '-'
|
||||
$sourceDir = (Resolve-Path "..\zoitechat-build\${{ matrix.platform }}\rel").Path
|
||||
$outputPath = Join-Path $PWD "ZoiteChat-${safeVersion}_${{ matrix.arch }}.msix"
|
||||
|
||||
# Reserved ZoiteChat identity from the release branch. Adjust only
|
||||
# if the Product identity page in Partner Center changes.
|
||||
$identityName = 'deependtilde.ZoiteChat'
|
||||
$publisher = 'CN=6C7766BB-6AC6-488B-A09E-E3F8E68E63E4'
|
||||
$publisherDisplayName = 'deepend_tilde'
|
||||
$artifactName = 'MSIX Store Package ${{ matrix.arch }}'
|
||||
|
||||
Write-Host "Building Store MSIX for identity: $identityName"
|
||||
|
||||
& .\win32\msix\build-msix.ps1 `
|
||||
-SourceDir $sourceDir `
|
||||
-OutputPath $outputPath `
|
||||
-PackageVersion $packageVersion `
|
||||
-IdentityName $identityName `
|
||||
-Publisher $publisher `
|
||||
-PublisherDisplayName $publisherDisplayName `
|
||||
-SelfSign
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'MSIX build script failed'
|
||||
}
|
||||
|
||||
if (-not (Test-Path $outputPath)) {
|
||||
throw "Expected MSIX was not created: $outputPath"
|
||||
}
|
||||
|
||||
"path=$outputPath" >> $env:GITHUB_OUTPUT
|
||||
"filename=$(Split-Path $outputPath -Leaf)" >> $env:GITHUB_OUTPUT
|
||||
"package_version=$packageVersion" >> $env:GITHUB_OUTPUT
|
||||
"store_ready=true" >> $env:GITHUB_OUTPUT
|
||||
"artifact_name=$artifactName" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Upload MSI Installer
|
||||
id: upload_msi
|
||||
uses: actions/upload-artifact@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 }}
|
||||
@@ -27,3 +27,9 @@ osx/.ZoiteChat.app
|
||||
*.dmg
|
||||
*.VC.db
|
||||
*.VC.opendb
|
||||
|
||||
# Native macOS packaging output
|
||||
/build-macos*/
|
||||
/dist/
|
||||
/packaging/macos/__pycache__/
|
||||
/packaging/macos/tests/__pycache__/
|
||||
+48
-5
@@ -33,10 +33,53 @@ Example:
|
||||
that is not visually indented
|
||||
```
|
||||
|
||||
## Read the Docs theme skin
|
||||
## HTML theme
|
||||
|
||||
The HTML output keeps the existing `basicstrap` Sphinx theme, then applies the
|
||||
ZoiteChat main-site skin from `_static/css/zoitechat-docs.css`.
|
||||
The `zoitechat` theme inherits Sphinx's built-in `basic` theme. It uses the
|
||||
same compact layout, blue navigation, and system fonts as zoitechat.org.
|
||||
Appearance starts in System mode on each page. Light and Dark are available
|
||||
for the current page; no cookies or browser storage are used.
|
||||
|
||||
Brand navigation links are set in `conf.py` through `html_context`, and the
|
||||
header logo uses `_static/zoitechat-logo.svg`.
|
||||
The theme's templates are in `_theme/zoitechat/`. Its readable CSS and
|
||||
JavaScript are in `_static/css/zoitechat-docs.css` and
|
||||
`_static/js/zoitechat-docs.js`. No minification, JavaScript package manager,
|
||||
external fonts, or CDN assets are required.
|
||||
|
||||
Navigation URLs, including `zoite_atlas_url`, are configured through
|
||||
`html_context` in `conf.py`. The logo
|
||||
is `_static/zoitechat-logo.svg`. Standard Sphinx search, indexes, source
|
||||
links, anchors, translations, and previous/next links remain available.
|
||||
The HTML output also works as locally installed documentation; project-site
|
||||
links only access the network when clicked.
|
||||
|
||||
Build from the repository root:
|
||||
|
||||
```sh
|
||||
sphinx-build -b html docs docs/_build/html
|
||||
```
|
||||
|
||||
Use a clean output directory when switching themes, so stale Basicstrap
|
||||
assets from an earlier build are not included in a package.
|
||||
|
||||
## Debian packaging
|
||||
|
||||
The active theme does not use the bundled Basicstrap, Bootstrap, jQuery,
|
||||
jQuery Cookie, Font Awesome, or icon fonts. Its font stack includes DejaVu
|
||||
Sans and DejaVu Sans Mono; no font files are bundled by the theme.
|
||||
|
||||
Packagers may exclude `docs/_theme/basicstrap/` from the source package.
|
||||
The default theme continues to build without that directory. No pre-minified
|
||||
third-party assets need to be added or rebuilt for this theme.
|
||||
|
||||
Build with the distribution's `python3-sphinx`. After installing the HTML
|
||||
into the package staging directory, use `dh_sphinxdoc` (or `dh --with
|
||||
sphinxdoc`) to replace Sphinx's standard JavaScript files with system
|
||||
symlinks. Include `${sphinxdoc:Depends}` in the package dependencies and
|
||||
`${sphinxdoc:Built-Using}` where required for generated assets. The custom
|
||||
`zoitechat-docs.js` remains a small, readable project source file.
|
||||
|
||||
A built-in theme is still selectable with `-D html_theme=classic` if a
|
||||
packager needs a different layout. The old RST content has existing build
|
||||
diagnostics; changing the theme does not suppress or alter them.
|
||||
|
||||
Shared website chrome is in `_static/css/zoitechat-site.css`, copied from the main site. Keep that readable stylesheet in sync when changing the shared header, navigation, typography, or colours.
|
||||
Vendored
+694
-2281
File diff suppressed because it is too large.
Load diff
Vendored
+526
@@ -0,0 +1,526 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
/* Shared chrome copied from ZoiteChat/site assets/css/site.css (1df996a). */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #fff;
|
||||
--surface: #fff;
|
||||
--chrome: #f1f2f3;
|
||||
--chrome2: #e3e5e7;
|
||||
--text: #25282c;
|
||||
--muted: #58616b;
|
||||
--line: #bdc3c9;
|
||||
--soft-line: #dce0e4;
|
||||
--blue: #215a9e;
|
||||
--link: #174f92;
|
||||
--selection: #e6effa;
|
||||
--success: #236741;
|
||||
--code: #f2f4f6;
|
||||
--shadow: none;
|
||||
--mono: Consolas,"Liberation Mono",monospace;
|
||||
}
|
||||
|
||||
html[data-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bg: #111416;
|
||||
--surface: #191c1f;
|
||||
--chrome: #25292d;
|
||||
--chrome2: #30353a;
|
||||
--text: #eef0f2;
|
||||
--muted: #b2bbc4;
|
||||
--line: #50575e;
|
||||
--soft-line: #353c43;
|
||||
--blue: #2865a8;
|
||||
--link: #8dc0ff;
|
||||
--selection: #233951;
|
||||
--success: #92d5ab;
|
||||
--code: #111518;
|
||||
--shadow: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button, input, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button, summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
button, a, input, select, summary {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--link);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none!important;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: -100px;
|
||||
z-index: 10;
|
||||
padding: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 0 0 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 38px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 28px;
|
||||
letter-spacing: -.8px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.appearance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 5px 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
background: var(--chrome);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.nav-links>a, .nav-menu summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 6px 15px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.nav-links>a[aria-current=page] {
|
||||
color: #fff;
|
||||
border-bottom-color: var(--blue);
|
||||
font-weight: 500;
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.nav-links>a:hover, .nav-menu summary:hover {
|
||||
background: var(--selection);
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-menu summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-menu summary::after {
|
||||
content: "⌄";
|
||||
margin-left: 9px;
|
||||
}
|
||||
|
||||
.nav-menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-menu-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 5;
|
||||
min-width: 280px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.nav-menu-panel a {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-menu-panel a:hover {
|
||||
background: var(--selection);
|
||||
}
|
||||
|
||||
.nav-menu-panel span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.site-main {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(30px,3.9vw,46px);
|
||||
line-height: 1.16;
|
||||
letter-spacing: -1.5px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 17px 0;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.footer-copy {
|
||||
font-size: 11px;
|
||||
max-width: 62ch;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 14px;
|
||||
max-width: 400px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width:1000px) {
|
||||
.navigation {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.nav-links>a, .nav-menu summary {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:650px) {
|
||||
.topbar {
|
||||
padding: 13px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 28px;
|
||||
height: 31px;
|
||||
}
|
||||
|
||||
.appearance {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.appearance>span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appearance select {
|
||||
font-size: 12px;
|
||||
max-width: 98px;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.nav-links>a, .nav-menu summary {
|
||||
min-height: 44px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 34px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 20px 17px;
|
||||
}
|
||||
|
||||
.nav-menu-panel {
|
||||
position: fixed;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
top: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion:reduce) {
|
||||
* {
|
||||
scroll-behavior: auto!important;
|
||||
}
|
||||
}
|
||||
|
||||
.appearance select {
|
||||
font-size: 12px;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
|
||||
.nav-links>a:hover {
|
||||
background: var(--selection);
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -.5px;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 16px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@media (max-width:1100px) {
|
||||
.navigation {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:800px) {
|
||||
.nav-links>a, .nav-menu summary {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:600px) {
|
||||
.topbar {
|
||||
padding: 0 0 14px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 32px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.nav-links>a, .nav-menu summary {
|
||||
padding: 7px 9px;
|
||||
min-height: 38px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
margin-top: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) {
|
||||
color-scheme: dark;
|
||||
--bg: #111416;
|
||||
--surface: #191c1f;
|
||||
--chrome: #25292d;
|
||||
--chrome2: #30353a;
|
||||
--text: #eef0f2;
|
||||
--muted: #b2bbc4;
|
||||
--line: #50575e;
|
||||
--soft-line: #353c43;
|
||||
--blue: #2865a8;
|
||||
--link: #8dc0ff;
|
||||
--selection: #233951;
|
||||
--success: #92d5ab;
|
||||
--code: #111518;
|
||||
--shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.site-header, .site-main, .site-footer {
|
||||
width: min(1040px, calc(100% - 48px));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.site-main {
|
||||
padding-block: 28px 20px;
|
||||
}
|
||||
|
||||
.site-header .topbar {
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.site-header .navigation {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.site-header, .site-main, .site-footer {
|
||||
width: calc(100% - 30px);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.site-main {
|
||||
padding-top: 23px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Match the browser's heading defaults used by the main site. */
|
||||
h2, h3 { font-weight: bold; }
|
||||
h2 { margin-top: .83em; }
|
||||
h3 { margin-block: 1em; }
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const initialise = () => {
|
||||
const picker = document.getElementById('theme-picker');
|
||||
const preference = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
if (picker instanceof HTMLSelectElement) {
|
||||
const applyTheme = () => {
|
||||
const dark = picker.value === 'dark'
|
||||
|| (picker.value === 'system' && preference.matches);
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||
document.querySelectorAll('meta[name="theme-color"]').forEach((meta) => {
|
||||
meta.setAttribute('content', dark ? '#111416' : '#ffffff');
|
||||
});
|
||||
};
|
||||
|
||||
// Follow the OS on every visit, including browser back/forward navigation.
|
||||
const resetTheme = () => {
|
||||
picker.value = 'system';
|
||||
applyTheme();
|
||||
};
|
||||
picker.addEventListener('change', applyTheme);
|
||||
preference.addEventListener('change', applyTheme);
|
||||
window.addEventListener('pageshow', resetTheme);
|
||||
resetTheme();
|
||||
}
|
||||
|
||||
const navigation = document.getElementById('docs-navigation');
|
||||
const compact = window.matchMedia('(max-width: 760px)');
|
||||
if (navigation instanceof HTMLDetailsElement) {
|
||||
const fitNavigation = () => { navigation.open = !compact.matches; };
|
||||
compact.addEventListener('change', fitNavigation);
|
||||
fitNavigation();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initialise, { once: true });
|
||||
} else {
|
||||
initialise();
|
||||
}
|
||||
})();
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{# SPDX-License-Identifier: GPL-2.0-or-later #}
|
||||
<nav class="global-toc" aria-label="{{ _('Documentation contents') }}">
|
||||
<h2>{{ _('Contents') }}</h2>
|
||||
{{ toctree(maxdepth=2, collapse=true, includehidden=true, titles_only=true) }}
|
||||
</nav>
|
||||
Vendored
+108
@@ -0,0 +1,108 @@
|
||||
{# SPDX-License-Identifier: GPL-2.0-or-later #}
|
||||
{% extends "basic/layout.html" %}
|
||||
{% set docs_root = root_doc|default(master_doc) %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ super() }}
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#111416" media="(prefers-color-scheme: dark)">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_tag %}<body id="top">{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<a class="skip-link" href="#main-content">{{ _('Skip to content') }}</a>
|
||||
<header class="site-header">
|
||||
<div class="topbar">
|
||||
<a class="brand" href="{{ pathto(docs_root)|e }}">
|
||||
<img src="{{ logo_url|default(pathto('_static/zoitechat-logo.svg', 1), true)|e }}" alt="" width="38" height="42">
|
||||
<strong>{{ project|e }}</strong>
|
||||
</a>
|
||||
<label class="appearance" for="theme-picker">
|
||||
<span>{{ _('Appearance') }}</span>
|
||||
<select id="theme-picker" aria-label="{{ _('Documentation appearance') }}">
|
||||
<option value="system" selected>{{ _('System') }}</option>
|
||||
<option value="light">{{ _('Light') }}</option>
|
||||
<option value="dark">{{ _('Dark') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="navigation">
|
||||
<nav class="nav-links" aria-label="{{ _('Primary') }}">
|
||||
<a href="{{ zoite_site_url|e }}">{{ _('Home') }}</a>
|
||||
<a href="{{ zoite_download_url|e }}">{{ _('Downloads') }}</a>
|
||||
<a href="{{ zoite_addons_url|e }}">{{ _('Addons') }}</a>
|
||||
<a href="{{ zoite_announcements_url|e }}">{{ _('Announcements') }}</a>
|
||||
<a href="{{ pathto(docs_root)|e }}" aria-current="page">{{ _('Documentation') }}</a>
|
||||
<a href="{{ zoite_atlas_url|e }}">{{ _('Atlas') }}</a>
|
||||
<a href="{{ zoite_github_url|e }}" target="_blank" rel="noopener">{{ _('GitHub') }}</a>
|
||||
<details class="nav-menu">
|
||||
<summary>{{ _('IRC support') }}</summary>
|
||||
<div class="nav-menu-panel" aria-label="{{ _('IRC support channels') }}">
|
||||
<a href="{{ zoite_irc_url|e }}" target="_blank" rel="noopener"><strong>{{ _('Zoite IRC') }}</strong><span>{{ _('Official support') }} · #zoitechat</span></a>
|
||||
<a href="{{ libera_irc_url|e }}" target="_blank" rel="noopener"><strong>{{ _('Libera.Chat') }}</strong><span>{{ _('Secondary support') }} · #zoitechat</span></a>
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block relbar1 %}{% endblock %}
|
||||
{% block relbar2 %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="docs-layout{% if not render_sidebar %} no-sidebar{% endif %}">
|
||||
{% if render_sidebar %}
|
||||
<aside class="docs-sidebar" aria-label="{{ _('Documentation navigation') }}">
|
||||
<details class="docs-navigation" id="docs-navigation" open>
|
||||
<summary>{{ _('Documentation') }}</summary>
|
||||
<div class="sidebar-content">
|
||||
{% for sidebartemplate in sidebars %}
|
||||
{% include sidebartemplate %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
{% endif %}
|
||||
<main class="docs-content" id="main-content" tabindex="-1">
|
||||
<div class="docs-location">
|
||||
<nav aria-label="{{ _('Breadcrumbs') }}">
|
||||
<a href="{{ pathto(docs_root)|e }}">{{ _('Documentation') }}</a>
|
||||
{% for parent in parents %}
|
||||
<span aria-hidden="true">/</span> <a href="{{ parent.link|e }}">{{ parent.title }}</a>
|
||||
{% endfor %}
|
||||
{% if pagename != docs_root %}<span aria-hidden="true">/</span> <span>{{ title|striptags|e }}</span>{% endif %}
|
||||
</nav>
|
||||
<span class="docs-version">{{ release|e }}</span>
|
||||
</div>
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
{% if prev or next %}
|
||||
<nav class="page-relations" aria-label="{{ _('Related pages') }}">
|
||||
<div>
|
||||
{% if prev %}<small>{{ _('Previous') }}</small><a href="{{ prev.link|e }}" rel="prev">{{ prev.title }}</a>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
{% if next %}<small>{{ _('Next') }}</small><a href="{{ next.link|e }}" rel="next">{{ next.title }}</a>{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<footer class="site-footer">
|
||||
<div class="footer-grid">
|
||||
<p class="footer-copy">{{ project|e }} {{ _('documentation') }} · {{ release|e }}</p>
|
||||
<nav class="footer-links" aria-label="{{ _('Footer navigation') }}">
|
||||
<a href="{{ pathto(docs_root)|e }}">{{ _('Contents') }}</a>
|
||||
<a href="{{ zoite_atlas_url|e }}">{{ _('Atlas') }}</a>
|
||||
{% if hasdoc('search') %}<a href="{{ pathto('search')|e }}">{{ _('Search') }}</a>{% endif %}
|
||||
{% if hasdoc('genindex') %}<a href="{{ pathto('genindex')|e }}">{{ _('Index') }}</a>{% endif %}
|
||||
<a href="#top">{{ _('Back to top') }}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{# SPDX-License-Identifier: GPL-2.0-or-later #}
|
||||
{% if display_toc %}
|
||||
<nav class="local-toc" aria-label="{{ _('On this page') }}">
|
||||
<h2>{{ _('On this page') }}</h2>
|
||||
{{ toc }}
|
||||
</nav>
|
||||
{% endif %}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{# SPDX-License-Identifier: GPL-2.0-or-later #}
|
||||
{% if pagename != "search" %}
|
||||
<form class="docs-search" action="{{ pathto('search')|e }}" method="get" role="search">
|
||||
<label for="docs-search-input">{{ _('Search documentation') }}</label>
|
||||
<div class="search-inputs">
|
||||
<input id="docs-search-input" type="search" name="q" placeholder="{{ _('Search documentation') }}" autocomplete="off" spellcheck="false">
|
||||
<button type="submit">{{ _('Search') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
[theme]
|
||||
inherit = basic
|
||||
stylesheet = basic.css
|
||||
pygments_style = sphinx
|
||||
sidebars = searchbox.html, globaltoc.html, localtoc.html, sourcelink.html
|
||||
|
||||
[options]
|
||||
+10
-8
@@ -99,7 +99,7 @@ trim_footnote_reference_space = True
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
html_theme = 'basicstrap'
|
||||
html_theme = 'zoitechat'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
@@ -127,26 +127,28 @@ html_context = {
|
||||
'zoite_site_url': 'https://zoitechat.org/',
|
||||
'zoite_download_url': 'https://zoitechat.org/download.php',
|
||||
'zoite_addons_url': 'https://zoitechat.org/addons.php',
|
||||
'zoite_announcements_url': 'https://zoitechat.org/announcements.php',
|
||||
'zoite_github_url': 'https://github.com/ZoiteChat/zoitechat',
|
||||
'zoite_atlas_url': 'https://atlas.zoitechat.org/',
|
||||
'zoite_irc_url': 'https://zoite.net/webchat/?join=%23zoitechat',
|
||||
'libera_irc_url': 'https://web.libera.chat/#zoitechat',
|
||||
}
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
html_logo = 'zoitechat-logo.svg'
|
||||
html_logo = '_static/zoitechat-logo.svg'
|
||||
|
||||
# The name of an image file (within the static path) to use as favicon of the
|
||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
# pixels large.
|
||||
html_favicon = '_static/favicon.ico'
|
||||
# Use the ZoiteChat logo instead of the old HexChat favicon.
|
||||
html_favicon = '_static/zoitechat-logo.svg'
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Main-site visual skin for Read the Docs/Sphinx HTML output.
|
||||
html_css_files = ['css/zoitechat-docs.css']
|
||||
# Local, readable assets shared by hosted and offline HTML documentation.
|
||||
html_css_files = ['css/zoitechat-docs.css', 'css/zoitechat-site.css']
|
||||
html_js_files = ['js/zoitechat-docs.js']
|
||||
|
||||
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||
# using the given strftime format.
|
||||
|
||||
+1
-1
Submodule flatpak/shared-modules updated: 55a86b3da4...a58e654b91.
+19
-1
@@ -13,7 +13,9 @@ gnome = import('gnome')
|
||||
cc = meson.get_compiler('c')
|
||||
|
||||
|
||||
libgio_dep = dependency('gio-2.0', version: '>= 2.36.0')
|
||||
# The relocatable macOS runtime sets the GIO TLS database explicitly.
|
||||
libgio_dep = dependency('gio-2.0',
|
||||
version: host_machine.system() == 'darwin' ? '>= 2.60.0' : '>= 2.36.0')
|
||||
libgmodule_dep = dependency('gmodule-2.0')
|
||||
|
||||
libcanberra_dep = dependency('libcanberra', version: '>= 0.22',
|
||||
@@ -29,7 +31,13 @@ else
|
||||
required: get_option('tls'))
|
||||
endif
|
||||
|
||||
gtk_mac_dep = disabler()
|
||||
if host_machine.system() == 'darwin' and get_option('gtk-frontend')
|
||||
gtk_mac_dep = dependency('gtk-mac-integration-gtk3', required: get_option('mac-integration'))
|
||||
endif
|
||||
|
||||
config_h = configuration_data()
|
||||
config_h.set('HAVE_GTK_MAC', host_machine.system() == 'darwin' and gtk_mac_dep.found())
|
||||
config_h.set_quoted('PACKAGE_VERSION', meson.project_version())
|
||||
config_h.set_quoted('PACKAGE_NAME', meson.project_name())
|
||||
config_h.set_quoted('GETTEXT_PACKAGE', 'zoitechat')
|
||||
@@ -99,6 +107,12 @@ global_cflags = [
|
||||
'-DGLIB_VERSION_MAX_ALLOWED=GLIB_VERSION_2_36',
|
||||
'-DGLIB_VERSION_MIN_REQUIRED=GLIB_VERSION_2_36',
|
||||
]
|
||||
if host_machine.system() == 'darwin'
|
||||
global_cflags = [
|
||||
'-DGLIB_VERSION_MAX_ALLOWED=GLIB_VERSION_2_60',
|
||||
'-DGLIB_VERSION_MIN_REQUIRED=GLIB_VERSION_2_60',
|
||||
]
|
||||
endif
|
||||
test_cflags = [
|
||||
'-funsigned-char',
|
||||
'-Wno-conversion',
|
||||
@@ -159,6 +173,10 @@ foreach ldflag : test_ldflags
|
||||
global_ldflags += ldflag
|
||||
endif
|
||||
endforeach
|
||||
if host_machine.system() == 'darwin'
|
||||
# Leave room for install_name_tool to rewrite packaged dependencies.
|
||||
global_ldflags += '-Wl,-headerpad_max_install_names'
|
||||
endif
|
||||
add_project_link_arguments(global_ldflags, language: 'c')
|
||||
|
||||
subdir('src')
|
||||
|
||||
@@ -23,6 +23,10 @@ option('appindicator', type: 'feature', value: 'auto',
|
||||
description: 'Use Ayatana/AppIndicator-based tray backend for GTK frontend (non-Windows only)'
|
||||
)
|
||||
|
||||
# macOS only; ignored by other platforms.
|
||||
option('mac-integration', type: 'feature', value: 'auto',
|
||||
description: 'Native macOS menu bar, Dock and application lifecycle')
|
||||
|
||||
# Install options
|
||||
option('dbus-service-use-appid', type: 'boolean', value: false,
|
||||
description: 'Rename dbus service to match app-id, required for Flatpak'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
brew "meson"
|
||||
brew "ninja"
|
||||
brew "pkgconf"
|
||||
brew "python"
|
||||
brew "gettext"
|
||||
brew "gtk+3"
|
||||
brew "gtk-mac-integration"
|
||||
brew "libarchive"
|
||||
brew "openssl@3"
|
||||
brew "lua"
|
||||
brew "librsvg"
|
||||
brew "glib-networking"
|
||||
brew "adwaita-icon-theme"
|
||||
brew "enchant"
|
||||
brew "ca-certificates"
|
||||
@@ -0,0 +1,417 @@
|
||||
# Native macOS build and distribution
|
||||
|
||||
This builds GTK3 with the native Quartz backend, macOS menus/Dock integration,
|
||||
UserNotifications, translations, bundled emoji, TLS, image loaders and supported
|
||||
native plugins. It creates a self-contained `ZoiteChat.app`; end users do not
|
||||
need Homebrew, XQuartz or Python. No system-wide ZoiteChat installation occurs.
|
||||
|
||||
The source changes are conditional on Darwin. Windows and Linux retain their
|
||||
existing notification backends and feature defaults. This adds a separate
|
||||
macOS workflow; it does not replace existing workflows.
|
||||
|
||||
## 1. Install build prerequisites
|
||||
|
||||
Use a Mac with a currently supported macOS release and native Homebrew. On Apple
|
||||
Silicon, use ARM Homebrew (`/opt/homebrew`), not Intel Homebrew under Rosetta.
|
||||
Intel builds use Intel Homebrew (`/usr/local`). Install Xcode command-line tools
|
||||
and Homebrew from https://brew.sh if absent. Full current Xcode is recommended
|
||||
for distribution and needed to meet the current App Store submission SDK rules.
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
# Run once if the command-line tools are not installed; let installation finish.
|
||||
|
||||
cd /path/to/zoitechat
|
||||
brew bundle --file packaging/macos/Brewfile
|
||||
python3 packaging/macos/build.py
|
||||
open dist/ZoiteChat.app
|
||||
```
|
||||
|
||||
The build compiles the app, runs its Meson tests, stages an installation within
|
||||
`build-macos/macos-stage`, bundles and rewrites every non-system Mach-O dependency,
|
||||
creates the icon and plist, audits the result, and signs it ad hoc for local use.
|
||||
It refuses to overwrite an existing output app. For another build, choose a new
|
||||
output path, or explicitly delete your old generated app before rebuilding:
|
||||
|
||||
```sh
|
||||
python3 packaging/macos/build.py --output dist/ZoiteChat-test2.app --build-number 2
|
||||
```
|
||||
|
||||
The same build directory can be reconfigured. Use a different build directory
|
||||
for another architecture or the experimental store variant. Do not change
|
||||
architectures within one Meson build directory.
|
||||
|
||||
Homebrew packages change over time; this is a repeatable procedure, not a
|
||||
bit-for-bit reproducible dependency lock. The bundle records dependency versions
|
||||
in `Contents/Resources/homebrew-dependencies.json`. Retain that record and the
|
||||
release's source commit. For a reproducible release, archive the exact dependency
|
||||
sources and build them in a controlled prefix, or maintain pinned Homebrew formulae.
|
||||
|
||||
## 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
|
||||
python3 packaging/macos/smoke.py dist/ZoiteChat.app
|
||||
```
|
||||
|
||||
The smoke check copies the app to a path containing spaces, verifies its signature,
|
||||
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
|
||||
or unresolved load commands. The minimum macOS version in the plist is derived
|
||||
from **all** bundled binaries, including Homebrew libraries. Lowering that plist
|
||||
value does not make newer dependency bottles run on an older Mac.
|
||||
|
||||
Before releasing, test on a second Mac without Homebrew installed:
|
||||
|
||||
- Launch from Finder and from a relocated/read-only location; verify Dock and
|
||||
menu bar, preferences persistence, dark/light themes and normal shutdown.
|
||||
- Connect with verified TLS, exercise SASL authentication and reconnect.
|
||||
- Send Unicode/emoji, render SVG/PNG/JPEG icons/images, and test HTTPS image previews.
|
||||
- Verify clipboard shortcuts, input methods, notifications/permission denial,
|
||||
detach/reattach tabs, focus changes, unread highlights and notification clicks.
|
||||
- Send/receive/resume DCC, write downloads and logs, import/export themes/settings.
|
||||
- Test FiSH, Lua and DCC checksum plugins in the direct build.
|
||||
- Exercise accessibility with VoiceOver and keyboard-only navigation.
|
||||
|
||||
Direct builds include Lua, FiSH and checksum, and retain the plugin loader.
|
||||
Python/Perl are explicitly not bundled: reliable redistribution requires packaging
|
||||
and auditing their interpreter runtimes, standard libraries and extensions.
|
||||
Linux `/proc`/PCI sysinfo is not built on macOS. These are macOS build choices;
|
||||
other platforms' plugin defaults are unchanged. macOS uses the existing password
|
||||
storage fallback; this patch does not add a Keychain backend. Audit credentials
|
||||
storage before adopting a stronger macOS security claim.
|
||||
|
||||
Desktop URL registration (`irc://` in Finder/browser) is not advertised by the
|
||||
plist; command-line IRC URLs continue to work. A native open-URL event handler
|
||||
and single-instance forwarding need separate integration before registering those
|
||||
schemes. AppIndicator and Linux D-Bus scripting/single-instance support are not
|
||||
used on macOS.
|
||||
|
||||
## 3. Sign and notarize for direct downloads
|
||||
|
||||
Local ad-hoc builds are for testing; they are not notarized releases. For public
|
||||
release, enroll in Apple's Developer Program, install your **Developer ID
|
||||
Application** certificate with its private key, then sign using that identity.
|
||||
Use your actual certificate identity in place of the example:
|
||||
|
||||
```sh
|
||||
python3 packaging/macos/bundle.py sign dist/ZoiteChat.app \
|
||||
--identity 'Developer ID Application: YOUR NAME (TEAMID)' \
|
||||
--entitlements packaging/macos/direct.entitlements
|
||||
|
||||
# Store credentials once in your login Keychain; enter your own values interactively.
|
||||
xcrun notarytool store-credentials ZoiteChat-notary
|
||||
|
||||
ditto -c -k --keepParent dist/ZoiteChat.app dist/ZoiteChat-notarization.zip
|
||||
xcrun notarytool submit dist/ZoiteChat-notarization.zip \
|
||||
--keychain-profile ZoiteChat-notary --wait
|
||||
|
||||
# Only after notarization has succeeded:
|
||||
xcrun stapler staple dist/ZoiteChat.app
|
||||
xcrun stapler validate dist/ZoiteChat.app
|
||||
spctl --assess --type execute --verbose=2 dist/ZoiteChat.app
|
||||
|
||||
ditto -c -k --keepParent dist/ZoiteChat.app dist/ZoiteChat-macos.zip
|
||||
```
|
||||
|
||||
Signing runs inside-out, verifies the final bundle and enables hardened runtime.
|
||||
It does not use `codesign --deep` to sign. `--deep` is used only for verification.
|
||||
The direct entitlement allows users' independently signed native plugins; it
|
||||
relaxes library validation, not the entire hardened runtime. No JIT or executable
|
||||
memory entitlements are added. Do not change bundle contents after signing; signing
|
||||
again is required after any code/resource edit. Stapling is Apple's supported
|
||||
post-signing operation.
|
||||
|
||||
Publish exact corresponding source for ZoiteChat and satisfy the applicable
|
||||
licenses of each bundled dependency. Notices and dependency metadata are included,
|
||||
but those alone do not satisfy every source-distribution obligation. Review the
|
||||
notarization log if rejected; the script does not silently bypass Gatekeeper.
|
||||
|
||||
### Automate signing and notarization with GitHub Actions
|
||||
|
||||
The workflow now has a separate signed/notarized release job for both
|
||||
architectures. It uses Developer ID distribution for direct downloads, not Mac
|
||||
App Store signing. You can manage it from Windows/Linux; GitHub's macOS runners
|
||||
perform all signing, notarization and stapling.
|
||||
|
||||
Prepare these repository Actions secrets under **Settings → Secrets and
|
||||
variables → Actions**, or store them as environment secrets in **macos-release**.
|
||||
The certificate setting names are unchanged from the original instructions.
|
||||
The three `APPLE_API_*` settings are new notarization credentials:
|
||||
|
||||
| Name | Type | Value |
|
||||
| --- | --- | --- |
|
||||
| `MACOS_CERTIFICATE_P12_BASE64` | Secret | Base64-encoded Developer ID Application `.p12`, including its private key |
|
||||
| `MACOS_CERTIFICATE_PASSWORD` | Secret | Password used to export that `.p12` |
|
||||
| `MACOS_SIGNING_IDENTITY` | Variable, or Secret | Exact `Developer ID Application: YOUR NAME (TEAMID)` identity; the matching certificate's SHA-1 fingerprint is also accepted |
|
||||
| `APPLE_API_KEY_P8` | Secret | Entire plain-text App Store Connect **Team API key** `.p8` file, including PEM header/footer and newlines |
|
||||
| `APPLE_API_KEY_ID` | Secret | That API key's Key ID |
|
||||
| `APPLE_API_ISSUER_ID` | Secret | The Team API key's Issuer ID UUID; this is **not** your Apple Team ID |
|
||||
|
||||
Repository variables are under the **Variables** tab. If the identity is entered
|
||||
as both a variable and a secret, the variable takes precedence. No separate
|
||||
keychain password is needed; the job generates a temporary random password.
|
||||
The `.p8` is the notarization API key; it does not replace the signing `.p12`.
|
||||
No Apple ID password or app-specific password is used by this configuration.
|
||||
|
||||
To obtain the API key, open **App Store Connect → Users and Access → Integrations
|
||||
→ App Store Connect API → Team Keys → Generate API Key**. Choose **Developer**
|
||||
access for notarization. If API access is not enabled, the Account Holder must
|
||||
request it first. Download the `.p8` and record the Key ID and Issuer ID. Use a
|
||||
**Team** key with this workflow: individual API keys use different authentication
|
||||
and are not supported by this issuer-based configuration. Apple allows the
|
||||
private key download only once, so retain your copy securely.
|
||||
|
||||
For the certificate, export the Developer ID Application identity (certificate
|
||||
plus private key) from Keychain Access as a password-protected `.p12`.
|
||||
If you generated the CSR with OpenSSL on Linux/WSL, you can also create it there.
|
||||
Assuming Apple's downloaded DER certificate has been saved as
|
||||
`developerID_application.cer`, and you retained `zoitechat-signing.key`:
|
||||
|
||||
```sh
|
||||
openssl x509 -inform DER -in developerID_application.cer \
|
||||
-out zoitechat-signing.pem
|
||||
openssl pkcs12 -export -inkey zoitechat-signing.key \
|
||||
-in zoitechat-signing.pem -out ZoiteChat-signing.p12
|
||||
|
||||
# Enter the existing private-key password, then choose the .p12 export password.
|
||||
# Copy the resulting Base64 text into MACOS_CERTIFICATE_P12_BASE64:
|
||||
openssl base64 -A -in ZoiteChat-signing.p12 -out ZoiteChat-signing.p12.base64
|
||||
|
||||
# Read the certificate's commonName to obtain MACOS_SIGNING_IDENTITY:
|
||||
openssl x509 -in zoitechat-signing.pem -noout -subject -nameopt multiline
|
||||
```
|
||||
|
||||
Upload only the public CSR to Apple. Put the `.p12` Base64, export password and
|
||||
API credentials in GitHub settings, never in the repository or build artifacts.
|
||||
|
||||
Once these patches are committed to `master` and the settings are ready:
|
||||
|
||||
1. Open **Actions → macOS build → Run workflow**.
|
||||
2. Select **master** and enable **Sign with Developer ID and notarize using
|
||||
release credentials**.
|
||||
3. Run the workflow. It creates separate Apple Silicon and Intel release jobs.
|
||||
4. After success, download `ZoiteChat-macos-arm64-signed-notarized` or
|
||||
`ZoiteChat-macos-x86_64-signed-notarized`. The ZIP inside contains the signed,
|
||||
stapled app. Actions artifacts wrap it in their own download archive.
|
||||
|
||||
A push of a `v*` tag also triggers the signed release jobs automatically, so wait
|
||||
until credentials are configured before pushing a release tag. Normal branch
|
||||
pushes, pull requests and manual runs with the checkbox off continue to build
|
||||
ad-hoc test artifacts without any signing secrets. Manual credentialed releases
|
||||
are restricted to `master`; pull requests cannot invoke the release job.
|
||||
Missing/malformed release settings fail before the Homebrew/build steps rather
|
||||
than producing an unsigned artifact marked as a release.
|
||||
|
||||
The release jobs use the GitHub environment **macos-release**. Repository secrets
|
||||
work with it; environment secrets can override them. Configure environment
|
||||
branch/tag restrictions or required reviewers if that fits your repository's
|
||||
release process. This workflow does not automatically create a GitHub Release,
|
||||
publish to zoitechat.org, or upload to App Store Connect; it produces downloadable
|
||||
signed/notarized artifacts for you to publish.
|
||||
|
||||
The job imports the `.p12` into a temporary keychain, installs Apple's public
|
||||
Developer ID intermediate certificates, verifies a valid matching identity,
|
||||
and signs inside-out with hardened runtime and the direct plugin entitlement.
|
||||
It uses the existing bundle audit and then submits a ZIP with `notarytool`.
|
||||
After Apple reports **Accepted**, it staples the ticket to the `.app`, verifies
|
||||
stapling/signatures and Gatekeeper acceptance, and creates a **new** final ZIP.
|
||||
Private credential files and the temporary keychain are cleaned up on normal
|
||||
success/failure paths; hosted runner VMs are also discarded after the job.
|
||||
|
||||
The default Apple wait limit is **60 minutes** within a **180-minute** release
|
||||
job. A rejected, failed or still-pending submission produces no final release
|
||||
ZIP. The `ZoiteChat-macos-<arch>-notarization-log` artifact retains the submission
|
||||
ID, status and Apple's available diagnostic log. First submissions can take
|
||||
longer; check the saved ID with `notarytool info` and your API credentials. An
|
||||
accepted submission still requires stapling and repackaging before publication;
|
||||
rerunning this workflow starts a new build and submission. To increase the wait
|
||||
limit, update `--notary-timeout` and allow adequate job timeout for build plus wait.
|
||||
|
||||
Both architecture builds receive the same bundle build number for one workflow
|
||||
run. The API credentials are supplied only to the config-check/release steps,
|
||||
not to Homebrew, compiler tests, ordinary builds or pull-request jobs.
|
||||
|
||||
## 4. Mac App Store: experimental groundwork, not submission-ready
|
||||
|
||||
A working direct-download app is not sufficient for App Store submission. This
|
||||
repository inherits GPL-2.0-or-later code from XChat/HexChat and other contributors.
|
||||
App Store terms and GPL obligations require a licensing review; do not assume
|
||||
that signing, a custom EULA, or relicensing only your new changes resolves rights
|
||||
to inherited code. This patch changes no licenses or copyright permissions.
|
||||
|
||||
An isolated sandbox-test build is provided:
|
||||
|
||||
```sh
|
||||
python3 packaging/macos/build.py --store-experimental \
|
||||
--build-dir build-macos-store --output dist/ZoiteChat-store-test.app \
|
||||
--build-number 1
|
||||
|
||||
# Local sandbox experimentation (not an uploadable distribution signature):
|
||||
python3 packaging/macos/bundle.py sign dist/ZoiteChat-store-test.app \
|
||||
--identity - --entitlements packaging/macos/store-experimental.entitlements
|
||||
open dist/ZoiteChat-store-test.app
|
||||
```
|
||||
|
||||
The separate store variant excludes loadable plugins and scripting runtimes.
|
||||
The template grants sandboxed client/server networking, user-selected files and
|
||||
Downloads. The direct version retains its plugin functionality. This is an
|
||||
explicit experimental variant, not a change to Windows/Linux or direct builds.
|
||||
|
||||
Before a store package can be regarded as a release candidate:
|
||||
|
||||
1. Resolve the inherited GPL/dependency-license distribution question and review
|
||||
Apple's current SDK, content/chat moderation, privacy and accessibility rules.
|
||||
2. Register `org.Zoitechat` (or change the plist identifier consistently if already
|
||||
owned by another developer), create App Store Connect metadata, and choose a
|
||||
unique incrementing build number. Use a stable release, not a `-dev` snapshot.
|
||||
3. Test sandboxed settings/logs, notifications, SASL/TLS, HTTPS and DCC on a real
|
||||
Mac. GTK dialogs are not assumed to grant Powerbox access. Add native
|
||||
`NSOpenPanel`/`NSSavePanel` integration and security-scoped bookmarks for
|
||||
persistent access to user-selected files. The entitlement template alone does
|
||||
not solve persistent file access. `/EXEC`, custom external commands, user
|
||||
plugin loading and arbitrary external paths cannot be promised under sandbox.
|
||||
4. Add required privacy manifest declarations based on an actual API audit of the
|
||||
app and dependencies; do not submit an invented empty compliance declaration.
|
||||
5. Obtain matching App Store distribution application/installer certificates and
|
||||
a matching provisioning profile. Generate team-specific signing entitlements
|
||||
from the profile with Xcode; do not use Developer ID or the generic local-test
|
||||
template as a store distribution identity/profile.
|
||||
6. Embed the matching profile as `Contents/embedded.provisionprofile` **before**
|
||||
final signing. Sign embedded libraries with the distribution identity, then
|
||||
the app with its profile-authorized entitlements (use `bundle.py sign` with
|
||||
that identity and generated entitlement file). Package with Apple's
|
||||
`productbuild`, validate and upload with Transporter/App Store Connect, then
|
||||
test the App Store processed build via TestFlight before submission.
|
||||
|
||||
Example **after those prerequisites are satisfied**:
|
||||
|
||||
```sh
|
||||
# Use the actual prepared profile and team-specific entitlements:
|
||||
cp /path/to/ZoiteChat.provisionprofile \
|
||||
dist/ZoiteChat-store-test.app/Contents/embedded.provisionprofile
|
||||
python3 packaging/macos/bundle.py sign dist/ZoiteChat-store-test.app \
|
||||
--identity 'YOUR APP STORE APPLICATION CERTIFICATE IDENTITY' \
|
||||
--entitlements /path/to/ZoiteChat-distribution.entitlements
|
||||
productbuild --component dist/ZoiteChat-store-test.app /Applications \
|
||||
--sign 'YOUR APP STORE INSTALLER CERTIFICATE IDENTITY' dist/ZoiteChat-store.pkg
|
||||
```
|
||||
|
||||
No App Store approval, sandbox compatibility or legal compatibility is asserted.
|
||||
For an initial macOS release, the signed/notarized direct build is the practical
|
||||
path while those issues are resolved.
|
||||
|
||||
## 5. CI and architecture support
|
||||
|
||||
`.github/workflows/macos-build.yml` adds independent Apple Silicon and Intel jobs.
|
||||
Ordinary jobs install prerequisites, run tests, create/audit/ad-hoc-sign the
|
||||
bundle, check relocated startup and upload test ZIPs. They need no Apple secrets.
|
||||
The separate release jobs add Developer ID signing, Apple notarization, stapling
|
||||
and final verification. Only their successful output is named signed/notarized.
|
||||
|
||||
Homebrew may build unsupported Intel bottles from source; that can be slower.
|
||||
The workflow uses native runners and does not claim a universal application.
|
||||
Do not merge only the main executable with `lipo`: a universal release must merge
|
||||
and validate every bundled dylib, module and plugin plus matching resources.
|
||||
Signing must happen after that merge. Publish separate architecture downloads
|
||||
until a full universal dependency pipeline is available.
|
||||
|
||||
## References
|
||||
|
||||
- GTK on macOS: https://www.gtk.org/docs/installations/macos
|
||||
- GTK macOS backend: https://docs.gtk.org/gtk3/macos.html
|
||||
- Apple distribution signing: https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-the-mac
|
||||
- Apple packaging: https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution
|
||||
- GitHub certificate import: https://docs.github.com/en/actions/how-tos/deploy/deploy-to-third-party-platforms/sign-xcode-applications
|
||||
- Apple notarization authentication: https://developer.apple.com/documentation/technotes/tn3147-migrating-to-the-latest-notarization-tool
|
||||
- App Store Connect API keys: https://developer.apple.com/help/app-store-connect/get-started/app-store-connect-api/
|
||||
- Apple review rules (especially 1.2 and 2.4.5): https://developer.apple.com/app-store/review/guidelines/
|
||||
- Apple sandbox file access: https://developer.apple.com/documentation/security/accessing-files-from-the-macos-app-sandbox
|
||||
- FSF discussion of GPL and App Store terms (historical, not a current legal determination): https://www.fsf.org/blogs/licensing/more-about-the-app-store-gpl-enforcement
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile, test, stage and package ZoiteChat on the host Mac."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from bundle import ROOT, package, run
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--build-dir", type=Path, default=Path("build-macos"))
|
||||
parser.add_argument("--output", type=Path, default=Path("dist/ZoiteChat.app"))
|
||||
parser.add_argument("--build-number", default="1")
|
||||
parser.add_argument("--store-experimental", action="store_true", help="Build a separate sandbox test variant without loadable plugins; not submission approval")
|
||||
args = parser.parse_args()
|
||||
if platform.system() != "Darwin":
|
||||
parser.error("Build on macOS; cross-compiling from Linux is not supported")
|
||||
if args.output.exists():
|
||||
parser.error("Output already exists; use a new --output or explicitly remove the old app")
|
||||
build = args.build_dir.resolve()
|
||||
if build == ROOT or ROOT.is_relative_to(build):
|
||||
parser.error("Build directory must not contain the source checkout")
|
||||
run("xcrun", "--find", "clang")
|
||||
env = os.environ.copy()
|
||||
prefixes = [Path(run("brew", "--prefix", name)) for name in ("gettext", "openssl@3", "libarchive", "lua", "gtk+3", "gtk-mac-integration")]
|
||||
brew = Path(run("brew", "--prefix"))
|
||||
# Keg-only gettext, OpenSSL and libarchive are not necessarily in PATH/pkg-config.
|
||||
env["PATH"] = os.pathsep.join([str(p / "bin") for p in prefixes] + [str(brew / "bin"), env.get("PATH", "")])
|
||||
env["PKG_CONFIG_PATH"] = os.pathsep.join([str(p / "lib/pkgconfig") for p in prefixes] + [str(brew / "lib/pkgconfig"), str(brew / "share/pkgconfig"), env.get("PKG_CONFIG_PATH", "")])
|
||||
# Native dependency bottles determine the true deployment target; the bundle
|
||||
# auditor derives the final minimum rather than promising an arbitrary old OS.
|
||||
env.setdefault("MACOSX_DEPLOYMENT_TARGET", "11.0")
|
||||
backend = run("pkg-config", "--variable=targets", "gtk+-3.0", env=env).split()
|
||||
if "quartz" not in backend:
|
||||
parser.error(f"GTK must provide the native Quartz backend, got {backend}")
|
||||
# Do not silently select an XQuartz GTK stack or mix Intel/ARM Homebrew prefixes.
|
||||
machine = platform.machine()
|
||||
if machine not in ("arm64", "x86_64"):
|
||||
parser.error(f"Unsupported architecture: {machine}")
|
||||
options = [
|
||||
"--prefix=/", "--libdir=lib", "--buildtype=release", "-Dgtk-frontend=true",
|
||||
"-Dtext-frontend=false", "-Dmac-integration=enabled", "-Ddbus=disabled",
|
||||
"-Dlibcanberra=disabled", "-Dappindicator=disabled", "-Dinstall-appdata=false",
|
||||
"-Dwith-sysinfo=false", "-Dwith-python=false", "-Dwith-perl=false",
|
||||
"-Dwith-lua=lua", "-Dwith-fishlim=true", "-Dwith-checksum=true",
|
||||
"-Dplugin=" + ("false" if args.store_experimental else "true"),
|
||||
]
|
||||
if (build / "meson-private/coredata.dat").exists():
|
||||
options.insert(0, "--reconfigure")
|
||||
subprocess.run(["meson", "setup", str(build), str(ROOT), *options], env=env, check=True)
|
||||
subprocess.run(["meson", "compile", "-C", str(build)], env=env, check=True)
|
||||
subprocess.run(["meson", "test", "-C", str(build), "--print-errorlogs"], env=env, check=True)
|
||||
stage = build / "macos-stage"
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
subprocess.run(["meson", "install", "-C", str(build), "--destdir", str(stage)], env=env, check=True)
|
||||
package(stage, args.output.resolve(), args.build_number)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (RuntimeError, subprocess.CalledProcessError, OSError) as error:
|
||||
sys.exit(f"macOS build failed: {error}")
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and audit a relocatable GTK3 application. No third-party Python packages."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import plistlib
|
||||
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")}
|
||||
SYSTEM_ROOTS = ("/usr/lib/", "/System/Library/")
|
||||
|
||||
|
||||
def run(*args: str | Path, env: dict[str, str] | None = None) -> str:
|
||||
return subprocess.check_output([str(arg) for arg in args], env=env, text=True).strip()
|
||||
|
||||
|
||||
def is_macho(path: Path) -> bool:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
return False
|
||||
with path.open("rb") as stream:
|
||||
return stream.read(4) in MACH_MAGIC
|
||||
|
||||
|
||||
def dependencies(path: Path) -> list[str]:
|
||||
# Paths may contain spaces; splitting on whitespace would corrupt them.
|
||||
return re.findall(r"^\s+(.+?) \(compatibility version", run("otool", "-L", path), re.M)
|
||||
|
||||
|
||||
def rpaths(path: Path) -> list[str]:
|
||||
return re.findall(r"cmd LC_RPATH\s+cmdsize \d+\s+path (.+?) \(offset", run("otool", "-l", path))
|
||||
|
||||
|
||||
def dylib_id(path: Path) -> str | None:
|
||||
lines = run("otool", "-D", path).splitlines()
|
||||
return lines[1].strip() if len(lines) > 1 else None
|
||||
|
||||
|
||||
def system_library(name: str) -> bool:
|
||||
return name.startswith(SYSTEM_ROOTS)
|
||||
|
||||
|
||||
def expand_path(name: str, loader: Path, executable: Path) -> Path:
|
||||
return Path(name.replace("@loader_path", str(loader.parent)).replace("@executable_path", str(executable.parent)))
|
||||
|
||||
|
||||
def resolve_dependency(name: str, loader: Path, executable: Path, search: list[Path]) -> Path:
|
||||
if name.startswith("@rpath/"):
|
||||
suffix = name[len("@rpath/"):]
|
||||
candidates = [directory / suffix for directory in search]
|
||||
else:
|
||||
candidates = [expand_path(name, loader, executable)]
|
||||
for candidate in candidates:
|
||||
if candidate.is_absolute() and candidate.is_file():
|
||||
return candidate.resolve()
|
||||
raise RuntimeError(f"Cannot resolve {name!r} from {loader}; searched {candidates}")
|
||||
|
||||
|
||||
class Relocator:
|
||||
def __init__(self, app: Path, executable: Path):
|
||||
self.app = app
|
||||
self.executable = executable
|
||||
self.frameworks = app / "Contents/Frameworks"
|
||||
self.frameworks.mkdir(parents=True)
|
||||
self.sources: dict[Path, Path] = {}
|
||||
self.names: dict[str, Path] = {}
|
||||
self.visited: set[Path] = set()
|
||||
|
||||
def copy_dependency(self, source: Path) -> Path:
|
||||
source = source.resolve()
|
||||
if ".framework/" in str(source):
|
||||
raise RuntimeError(f"Third-party frameworks require separate packaging support: {source}")
|
||||
if source in self.sources:
|
||||
return self.sources[source]
|
||||
existing = self.names.get(source.name)
|
||||
if existing and existing != source:
|
||||
raise RuntimeError(f"Conflicting library basename: {existing} and {source}")
|
||||
target = self.frameworks / source.name
|
||||
shutil.copy2(source, target)
|
||||
target.chmod(target.stat().st_mode | 0o200)
|
||||
self.sources[source] = target
|
||||
self.names[source.name] = source
|
||||
return target
|
||||
|
||||
def relocate(self, source: Path, target: Path, inherited: list[Path] | None = None) -> None:
|
||||
source = source.resolve()
|
||||
if target in self.visited:
|
||||
return
|
||||
self.visited.add(target)
|
||||
local = [expand_path(name, source, self.executable) for name in rpaths(source)]
|
||||
search = local + (inherited or [])
|
||||
identity = dylib_id(source)
|
||||
# Keep the existing signature allocation while editing: stripping it
|
||||
# first can leave __LINKEDIT padding that install_name_tool rejects.
|
||||
# The final inside-out codesign --force pass replaces stale signatures.
|
||||
edits: list[str | Path] = []
|
||||
for name in dependencies(source):
|
||||
if name == identity or system_library(name):
|
||||
continue
|
||||
dependency = resolve_dependency(name, source, self.executable, search)
|
||||
destination = self.copy_dependency(dependency)
|
||||
relative = os.path.relpath(destination, target.parent)
|
||||
edits += ["-change", name, "@loader_path/" + relative]
|
||||
self.relocate(dependency, destination, search)
|
||||
if identity:
|
||||
edits += ["-id", "@rpath/" + target.name]
|
||||
for name in sorted(set(rpaths(target))):
|
||||
edits += ["-delete_rpath", name]
|
||||
if edits:
|
||||
run("install_name_tool", *edits, target)
|
||||
|
||||
|
||||
def copy_tree(source: Path, target: Path) -> None:
|
||||
if source.is_dir():
|
||||
# Dereference symlinks: never leave links into the builder's Cellar.
|
||||
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())
|
||||
minimum = (11, 0, 0)
|
||||
entries = []
|
||||
for path in sorted(app.rglob("*")):
|
||||
if path.is_symlink():
|
||||
resolved = path.resolve()
|
||||
if not resolved.is_relative_to(app.resolve()):
|
||||
raise RuntimeError(f"External symlink: {path} -> {resolved}")
|
||||
if not is_macho(path):
|
||||
continue
|
||||
if not architectures.issubset(set(run("lipo", "-archs", path).split())):
|
||||
raise RuntimeError(f"Architecture mismatch: {path}")
|
||||
identity = dylib_id(path)
|
||||
for name in dependencies(path):
|
||||
if name == identity or system_library(name):
|
||||
continue
|
||||
if not name.startswith("@loader_path/"):
|
||||
raise RuntimeError(f"Non-relocatable dependency: {path}: {name}")
|
||||
target = expand_path(name, path, binary).resolve()
|
||||
if not target.is_relative_to(app.resolve()) or not target.is_file():
|
||||
raise RuntimeError(f"Missing or external dependency: {path}: {name}")
|
||||
if rpaths(path):
|
||||
raise RuntimeError(f"Unexpected runtime search paths: {path}")
|
||||
load_commands = run("otool", "-l", path)
|
||||
versions = re.findall(r"\bminos (\d+(?:\.\d+){0,2})", load_commands)
|
||||
versions += re.findall(r"cmd LC_VERSION_MIN_MACOSX\s+cmdsize \d+\s+version (\d+(?:\.\d+){0,2})", load_commands)
|
||||
if not versions:
|
||||
raise RuntimeError(f"Missing deployment version: {path}")
|
||||
for version in versions:
|
||||
parts = tuple(int(value) for value in version.split("."))
|
||||
minimum = max(minimum, parts + (0,) * (3 - len(parts)))
|
||||
entries.append({"path": str(path.relative_to(app))})
|
||||
return {"architectures": sorted(architectures), "minimum_macos": ".".join(map(str, minimum if minimum[2] else minimum[:2])), "mach_o": entries}
|
||||
|
||||
|
||||
def package(stage: Path, app: Path, build_number: str) -> None:
|
||||
if not re.fullmatch(r"[1-9]\d*(?:\.\d+){0,2}", build_number):
|
||||
raise RuntimeError("Build number must be a positive Apple CFBundleVersion (for example 1 or 12.3)")
|
||||
if app.exists():
|
||||
raise RuntimeError(f"Output already exists; choose another --output or remove it explicitly: {app}")
|
||||
app.mkdir(parents=True)
|
||||
resources = app / "Contents/Resources"
|
||||
resources.mkdir(parents=True)
|
||||
binary = app / "Contents/MacOS/zoitechat"
|
||||
binary.parent.mkdir()
|
||||
shutil.copy2(stage / "bin/zoitechat", binary)
|
||||
copy_tree(stage / "share", resources / "share")
|
||||
copy_tree(stage / "lib/zoitechat/plugins", resources / "lib/zoitechat/plugins")
|
||||
relocator = Relocator(app, stage / "bin/zoitechat")
|
||||
seeds: list[tuple[Path, Path]] = [(stage / "bin/zoitechat", binary)]
|
||||
for source in (stage / "lib/zoitechat/plugins").glob("*"):
|
||||
if is_macho(source):
|
||||
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", "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"),
|
||||
(prefixes["librsvg"] / "lib/gdk-pixbuf-2.0/2.10.0/loaders", "lib/gdk-pixbuf-2.0/2.10.0/loaders"),
|
||||
(prefixes["gtk+3"] / "lib/gtk-3.0/3.0.0/immodules", "lib/gtk-3.0/3.0.0/immodules"),
|
||||
(prefixes["glib-networking"] / "lib/gio/modules", "lib/gio/modules"),
|
||||
(prefixes["enchant"] / "lib/enchant-2", "lib/enchant-2"),
|
||||
(prefixes["openssl@3"] / "lib/ossl-modules", "lib/ossl-modules"),
|
||||
]
|
||||
for source_dir, relative in module_groups:
|
||||
target_dir = resources / relative
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
for source in sorted(source_dir.glob("*")):
|
||||
if source.suffix not in (".so", ".dylib") or not source.is_file():
|
||||
continue
|
||||
target = target_dir / source.name
|
||||
if target.exists() and target.read_bytes() != source.read_bytes():
|
||||
raise RuntimeError(f"Conflicting module: {target}")
|
||||
shutil.copy2(source.resolve(), target)
|
||||
seeds.append((source.resolve(), target))
|
||||
enchant = (prefixes["enchant"] / "lib/libenchant-2.dylib").resolve()
|
||||
# Register Enchant at Resources/lib so its public prefix relocation API
|
||||
# finds Resources/lib/enchant-2 and Resources/share/enchant.
|
||||
enchant_alias = resources / "lib/libenchant-2.dylib"
|
||||
shutil.copy2(enchant, enchant_alias)
|
||||
relocator.sources[enchant] = enchant_alias
|
||||
relocator.names[enchant.name] = enchant
|
||||
seeds.append((enchant, enchant_alias))
|
||||
for formula, prefix in prefixes.items():
|
||||
for name in ("icons", "glib-2.0/schemas", "locale", "enchant", "hunspell"):
|
||||
copy_tree(prefix / "share" / name, resources / "share" / name)
|
||||
ca = prefixes["ca-certificates"] / "share/ca-certificates/cacert.pem"
|
||||
if not ca.is_file():
|
||||
raise RuntimeError(f"CA bundle not found: {ca}")
|
||||
(resources / "certs").mkdir()
|
||||
shutil.copy2(ca, resources / "certs/ca-bundle.crt")
|
||||
schemas = resources / "share/glib-2.0/schemas"
|
||||
run(prefixes["glib"] / "bin/glib-compile-schemas", schemas)
|
||||
pixbuf_modules = sorted((resources / "lib/gdk-pixbuf-2.0/2.10.0/loaders").glob("*"))
|
||||
if not any("svg" in path.name for path in pixbuf_modules):
|
||||
raise RuntimeError("SVG loader missing: install librsvg")
|
||||
if list((resources / "lib/zoitechat/plugins").glob("fishlim.*")) and not list((resources / "lib/ossl-modules").glob("legacy.*")):
|
||||
raise RuntimeError("FiSH needs the OpenSSL legacy provider in the bundle")
|
||||
if not list((resources / "lib/gio/modules").glob("*tls*")):
|
||||
raise RuntimeError("GIO TLS backend missing: install glib-networking")
|
||||
# Query original binaries before relocation, then rewrite only the cached paths.
|
||||
pixbuf_query = prefixes["gdk-pixbuf"] / "bin/gdk-pixbuf-query-loaders"
|
||||
if not pixbuf_query.exists():
|
||||
pixbuf_query = prefixes["gdk-pixbuf"] / "lib/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders"
|
||||
for tool, module_dir, cache in (
|
||||
(pixbuf_query, "lib/gdk-pixbuf-2.0/2.10.0/loaders", "pixbuf-loaders.cache"),
|
||||
(prefixes["gtk+3"] / "bin/gtk-query-immodules-3.0", "lib/gtk-3.0/3.0.0/immodules", "gtk-immodules.cache"),
|
||||
):
|
||||
originals = [(source, target) for source, target in seeds if target.parent == resources / module_dir]
|
||||
# Never call a query tool with zero arguments: it would scan global modules.
|
||||
data = run(tool, *(source for source, _ in originals)) if originals else "# No external input-method modules\n"
|
||||
for source, target in originals:
|
||||
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.
|
||||
write_fontconfig(resources, prefixes["fontconfig"])
|
||||
(resources / "zoitechat-bundle").write_text("1\n")
|
||||
licenses = resources / "licenses"
|
||||
licenses.mkdir()
|
||||
shutil.copy2(ROOT / "COPYING", licenses / "ZoiteChat-COPYING")
|
||||
# Preserve the notices shipped with every installed Homebrew dependency.
|
||||
# brew deps traverses dependencies by default; --recursive is not an option.
|
||||
wanted = set(prefixes)
|
||||
for formula in prefixes:
|
||||
wanted.update(run("brew", "deps", "--formula", formula).splitlines())
|
||||
installed = [item for item in json.loads(run("brew", "info", "--json=v2", "--installed"))["formulae"]
|
||||
if item["name"] in wanted]
|
||||
for formula in installed:
|
||||
prefix = Path(run("brew", "--prefix", formula["name"]))
|
||||
notices = [p for p in prefix.iterdir() if p.is_file() and re.match(r"(?i)(copying|license|copyright|notice)", p.name)]
|
||||
if notices:
|
||||
target = licenses / formula["name"].replace("/", "-")
|
||||
target.mkdir(exist_ok=True)
|
||||
for notice in notices:
|
||||
shutil.copy2(notice, target / notice.name)
|
||||
(resources / "homebrew-dependencies.json").write_text(json.dumps(installed, indent=2) + "\n")
|
||||
for source, target in seeds:
|
||||
relocator.relocate(source, target)
|
||||
report = audit(app)
|
||||
(resources / "bundle-audit.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
# Use the actual highest deployment target of the executable and all dylibs.
|
||||
# Setting an older plist version cannot make newer Homebrew bottles compatible.
|
||||
version = (ROOT / "VERSION").read_text().strip()
|
||||
release = re.match(r"\d+\.\d+\.\d+", version)
|
||||
if not release:
|
||||
raise RuntimeError(f"Unsupported release version: {version}")
|
||||
plist = {
|
||||
"CFBundleIdentifier": "org.Zoitechat", "CFBundleExecutable": "zoitechat",
|
||||
"CFBundleName": "ZoiteChat", "CFBundleDisplayName": "ZoiteChat",
|
||||
"CFBundlePackageType": "APPL", "CFBundleShortVersionString": release.group(),
|
||||
"CFBundleVersion": build_number, "CFBundleIconFile": "ZoiteChat.icns",
|
||||
"LSMinimumSystemVersion": report["minimum_macos"], "NSHighResolutionCapable": True,
|
||||
"NSPrincipalClass": "NSApplication",
|
||||
"LSApplicationCategoryType": "public.app-category.social-networking",
|
||||
"DTSDKName": "macosx" + run("xcrun", "--sdk", "macosx", "--show-sdk-version"),
|
||||
"DTSDKBuild": run("xcrun", "--sdk", "macosx", "--show-sdk-build-version"),
|
||||
}
|
||||
with (app / "Contents/Info.plist").open("wb") as stream:
|
||||
plistlib.dump(plist, stream)
|
||||
iconset = app.parent / (app.stem + ".iconset")
|
||||
if iconset.exists():
|
||||
raise RuntimeError(f"Icon staging directory already exists: {iconset}")
|
||||
iconset.mkdir()
|
||||
try:
|
||||
# Rasterize the original vector at 1024px, then generate every icon size.
|
||||
large = iconset / "source.png"
|
||||
run(prefixes["librsvg"] / "bin/rsvg-convert", "-w", "1024", "-h", "1024", "-o", large, ROOT / "data/icons/zoitechat.svg")
|
||||
for size in (16, 32, 128, 256, 512):
|
||||
for scale in (1, 2):
|
||||
name = f"icon_{size}x{size}" + ("@2x" if scale == 2 else "") + ".png"
|
||||
run("sips", "-z", str(size * scale), str(size * scale), large, "--out", iconset / name)
|
||||
large.unlink()
|
||||
run("iconutil", "-c", "icns", iconset, "-o", resources / "ZoiteChat.icns")
|
||||
finally:
|
||||
shutil.rmtree(iconset)
|
||||
# Local builds need an ad-hoc signature after install_name_tool changes.
|
||||
sign(app, "-", None)
|
||||
print(f"Created {app}: {', '.join(report['architectures'])}, macOS {report['minimum_macos']}+")
|
||||
|
||||
|
||||
def sign(app: Path, identity: str, entitlements: Path | None, *, keychain: Path | None = None) -> None:
|
||||
audit(app)
|
||||
binaries = [p for p in app.rglob("*") if is_macho(p) and p != app / "Contents/MacOS/zoitechat"]
|
||||
options = [] if identity == "-" else ["--timestamp", "--options", "runtime"]
|
||||
if keychain is not None:
|
||||
options += ["--keychain", str(keychain)]
|
||||
for binary in sorted(binaries, key=lambda p: len(p.parts), reverse=True):
|
||||
run("codesign", "--force", "--sign", identity, *options, binary)
|
||||
main_options = ["--entitlements", str(entitlements)] if entitlements else []
|
||||
run("codesign", "--force", "--sign", identity, *options, *main_options, app)
|
||||
run("codesign", "--verify", "--deep", "--strict", "--verbose=2", app)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="action", required=True)
|
||||
create = sub.add_parser("create")
|
||||
create.add_argument("--stage", type=Path, required=True)
|
||||
create.add_argument("--app", type=Path, required=True)
|
||||
create.add_argument("--build-number", default="1")
|
||||
verify = sub.add_parser("audit")
|
||||
verify.add_argument("app", type=Path)
|
||||
signing = sub.add_parser("sign")
|
||||
signing.add_argument("app", type=Path)
|
||||
signing.add_argument("--identity", required=True)
|
||||
signing.add_argument("--entitlements", type=Path)
|
||||
args = parser.parse_args()
|
||||
if platform.system() != "Darwin":
|
||||
parser.error("This tool requires macOS and the Xcode command-line tools")
|
||||
if args.action == "create":
|
||||
package(args.stage.resolve(), args.app.resolve(), args.build_number)
|
||||
elif args.action == "audit":
|
||||
print(json.dumps(audit(args.app.resolve()), indent=2))
|
||||
else:
|
||||
sign(args.app.resolve(), args.identity, args.entitlements)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (RuntimeError, subprocess.CalledProcessError, OSError) as error:
|
||||
sys.exit(f"macOS packaging failed: {error}")
|
||||
@@ -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)") }
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<!-- User-installed native plugins need library validation disabled. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key><true/>
|
||||
</dict></plist>
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Developer ID sign, notarize and staple a built app using CI environment secrets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from bundle import sign
|
||||
|
||||
REQUIRED = (
|
||||
"MACOS_CERTIFICATE_P12_BASE64", "MACOS_CERTIFICATE_PASSWORD",
|
||||
"MACOS_SIGNING_IDENTITY", "APPLE_API_KEY_P8", "APPLE_API_KEY_ID", "APPLE_API_ISSUER_ID",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(repr=False)
|
||||
class Credentials:
|
||||
certificate: bytes
|
||||
password: str
|
||||
identity: str
|
||||
api_key: str
|
||||
key_id: str
|
||||
issuer: str
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> Credentials:
|
||||
missing = [name for name in REQUIRED if not os.environ.get(name, "").strip()]
|
||||
if missing:
|
||||
raise RuntimeError("Missing release settings: " + ", ".join(missing))
|
||||
try:
|
||||
certificate = base64.b64decode("".join(os.environ[REQUIRED[0]].split()), validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
raise RuntimeError("MACOS_CERTIFICATE_P12_BASE64 must contain Base64-encoded .p12 bytes") from None
|
||||
if not certificate:
|
||||
raise RuntimeError("Signing certificate is empty")
|
||||
identity = os.environ["MACOS_SIGNING_IDENTITY"].strip()
|
||||
if not (identity.startswith("Developer ID Application:") or re.fullmatch(r"[a-fA-F0-9]{40}", identity)):
|
||||
raise RuntimeError("MACOS_SIGNING_IDENTITY must identify a Developer ID Application certificate")
|
||||
api_key = os.environ["APPLE_API_KEY_P8"].strip()
|
||||
if not (api_key.startswith("-----BEGIN PRIVATE KEY-----") and api_key.endswith("-----END PRIVATE KEY-----")):
|
||||
raise RuntimeError("APPLE_API_KEY_P8 must contain the complete, plain-text .p8 private key")
|
||||
key_id = os.environ["APPLE_API_KEY_ID"].strip()
|
||||
if not re.fullmatch(r"[A-Za-z0-9]{6,32}", key_id):
|
||||
raise RuntimeError("APPLE_API_KEY_ID must be an App Store Connect key ID")
|
||||
try:
|
||||
issuer = str(uuid.UUID(os.environ["APPLE_API_ISSUER_ID"].strip()))
|
||||
except ValueError:
|
||||
raise RuntimeError("APPLE_API_ISSUER_ID must be the Team API key issuer UUID") from None
|
||||
return cls(certificate, os.environ["MACOS_CERTIFICATE_PASSWORD"], identity, api_key, key_id, issuer)
|
||||
|
||||
|
||||
def execute(args: list[str | Path], operation: str, *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
# Do not print argv or CalledProcessError: security commands contain passwords.
|
||||
try:
|
||||
result = subprocess.run([str(arg) for arg in args], capture_output=True, text=True, check=False)
|
||||
except OSError:
|
||||
raise RuntimeError(f"{operation}: could not start the required tool") from None
|
||||
if check and result.returncode != 0:
|
||||
raise RuntimeError(f"{operation} failed (exit {result.returncode})")
|
||||
return result
|
||||
|
||||
|
||||
def write_private(path: Path, data: bytes) -> None:
|
||||
with path.open("xb") as stream:
|
||||
path.chmod(0o600)
|
||||
stream.write(data)
|
||||
|
||||
|
||||
def resolve_identity(output: str, requested: str) -> str:
|
||||
for fingerprint, name in re.findall(r'\b([A-Fa-f0-9]{40})\s+"([^"\n]+)"', output):
|
||||
if name.startswith("Developer ID Application:") and (requested == name or requested.upper() == fingerprint.upper()):
|
||||
return fingerprint
|
||||
raise RuntimeError("Imported .p12 has no valid Developer ID Application identity matching MACOS_SIGNING_IDENTITY; check its private key, password, expiry and certificate chain")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def signing_keychain(credentials: Credentials):
|
||||
original = shlex.split(execute(["security", "list-keychains", "-d", "user"], "Read keychain search list").stdout)
|
||||
with tempfile.TemporaryDirectory(prefix="zoitechat-signing-", dir=os.environ.get("RUNNER_TEMP")) as temporary:
|
||||
directory = Path(temporary)
|
||||
keychain = directory / "signing.keychain-db"
|
||||
keychain_password = secrets.token_urlsafe(32)
|
||||
certificate = directory / "signing.p12"
|
||||
api_key = directory / "notary.p8"
|
||||
write_private(certificate, credentials.certificate)
|
||||
write_private(api_key, (credentials.api_key + "\n").encode())
|
||||
try:
|
||||
execute(["security", "create-keychain", "-p", keychain_password, keychain], "Create signing keychain")
|
||||
execute(["security", "set-keychain-settings", "-lut", "21600", keychain], "Configure signing keychain")
|
||||
execute(["security", "unlock-keychain", "-p", keychain_password, keychain], "Unlock signing keychain")
|
||||
# Include both public Developer ID intermediates so signing does not
|
||||
# depend on the runner having cached the leaf certificate's issuer.
|
||||
for name in ("DeveloperIDCA.cer", "DeveloperIDG2CA.cer"):
|
||||
intermediate = directory / name
|
||||
try:
|
||||
with urllib.request.urlopen("https://www.apple.com/certificateauthority/" + name, timeout=60) as response:
|
||||
write_private(intermediate, response.read())
|
||||
except OSError:
|
||||
raise RuntimeError("Download of Apple's public Developer ID intermediate failed") from None
|
||||
execute(["security", "import", intermediate, "-k", keychain], "Import Developer ID intermediate")
|
||||
execute(["security", "import", certificate, "-P", credentials.password,
|
||||
"-k", keychain, "-T", "/usr/bin/codesign", "-T", "/usr/bin/security"], "Import signing .p12")
|
||||
execute(["security", "set-key-partition-list", "-S", "apple-tool:,apple:",
|
||||
"-k", keychain_password, keychain], "Allow non-interactive code signing")
|
||||
execute(["security", "list-keychains", "-d", "user", "-s", keychain, *original], "Add temporary signing keychain")
|
||||
identities = execute(["security", "find-identity", "-v", "-p", "codesigning", keychain], "Find signing identity").stdout
|
||||
yield keychain, api_key, resolve_identity(identities, credentials.identity)
|
||||
finally:
|
||||
# Hosted runners are discarded too, but clean up on every ordinary
|
||||
# success/failure path and restore the original search list.
|
||||
execute(["security", "list-keychains", "-d", "user", "-s", *original], "Restore keychain search list", check=False)
|
||||
if keychain.exists():
|
||||
result = execute(["security", "delete-keychain", keychain], "Delete signing keychain", check=False)
|
||||
if result.returncode:
|
||||
print("Warning: keychain deletion failed; temporary files will still be removed.", file=sys.stderr)
|
||||
|
||||
|
||||
def decode_response(result: subprocess.CompletedProcess[str], operation: str) -> dict:
|
||||
try:
|
||||
response = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise RuntimeError(f"{operation} did not return valid JSON (exit {result.returncode})") from None
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError(f"{operation} returned an unexpected response")
|
||||
return response
|
||||
|
||||
|
||||
def save_json(path: Path, response: dict) -> None:
|
||||
path.write_text(json.dumps(response, indent=2) + "\n")
|
||||
|
||||
|
||||
def notarize(app: Path, output: Path, diagnostics: Path, credentials: Credentials, api_key: Path, timeout: str) -> None:
|
||||
diagnostics.mkdir(parents=True, exist_ok=True)
|
||||
authentication = ["--key", str(api_key), "--key-id", credentials.key_id, "--issuer", credentials.issuer]
|
||||
with tempfile.TemporaryDirectory(prefix="zoitechat-notary-", dir=os.environ.get("RUNNER_TEMP")) as temporary:
|
||||
submission_zip = Path(temporary) / "ZoiteChat-submission.zip"
|
||||
execute(["ditto", "-c", "-k", "--keepParent", app, submission_zip], "Package notarization submission")
|
||||
result = execute(["xcrun", "notarytool", "submit", submission_zip, *authentication,
|
||||
"--output-format", "json"], "Submit notarization", check=False)
|
||||
submission = decode_response(result, "Notarization submission")
|
||||
save_json(diagnostics / "submission.json", submission)
|
||||
if result.returncode or not submission.get("id"):
|
||||
raise RuntimeError("Apple rejected the notarization submission; see submission.json")
|
||||
try:
|
||||
submission_id = str(uuid.UUID(submission["id"]))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise RuntimeError("Apple returned an invalid notarization submission ID") from None
|
||||
print(f"Notarization submitted: {submission_id}", flush=True)
|
||||
waited = execute(["xcrun", "notarytool", "wait", submission_id, *authentication,
|
||||
"--timeout", timeout, "--output-format", "json"], "Wait for notarization", check=False)
|
||||
try:
|
||||
status = decode_response(waited, "Notarization wait")
|
||||
except RuntimeError:
|
||||
status = {"id": submission_id, "status": "Unknown", "exit_code": waited.returncode}
|
||||
save_json(diagnostics / "status.json", status)
|
||||
# Apple can provide a diagnostic log after processing. Fetch it for both
|
||||
# successful and rejected submissions; never include credential files.
|
||||
execute(["xcrun", "notarytool", "log", submission_id, *authentication,
|
||||
diagnostics / "notary-log.json"], "Fetch notarization log", check=False)
|
||||
if waited.returncode or status.get("status") != "Accepted":
|
||||
raise RuntimeError("Notarization was not accepted or exceeded the wait limit; see the notarization diagnostic artifact. No release ZIP will be published")
|
||||
execute(["xcrun", "stapler", "staple", app], "Staple notarization ticket")
|
||||
execute(["xcrun", "stapler", "validate", app], "Validate notarization ticket")
|
||||
execute(["codesign", "--verify", "--deep", "--strict", "--verbose=2", app], "Verify stapled app signature")
|
||||
execute(["spctl", "--assess", "--type", "execute", "--verbose=2", app], "Assess Gatekeeper acceptance")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Repack only after stapling. The submission ZIP is not the distributable ZIP.
|
||||
execute(["ditto", "-c", "-k", "--keepParent", app, output], "Package notarized release")
|
||||
print(f"Signed and notarized release: {output}", flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check-config", action="store_true", help="Validate release settings without accessing Apple or a keychain")
|
||||
parser.add_argument("--app", type=Path, default=Path("dist/ZoiteChat.app"))
|
||||
parser.add_argument("--output", type=Path, default=Path("dist/ZoiteChat-signed-notarized.zip"))
|
||||
parser.add_argument("--diagnostics", type=Path, default=Path("dist/notarization"))
|
||||
parser.add_argument("--notary-timeout", default="60m")
|
||||
args = parser.parse_args()
|
||||
credentials = Credentials.from_environment()
|
||||
if args.check_config:
|
||||
print("Release signing and notarization settings are present and correctly formatted.")
|
||||
return
|
||||
if platform.system() != "Darwin":
|
||||
parser.error("Signing/notarization requires the macOS runner and Xcode command-line tools")
|
||||
if not args.app.is_dir() or args.output.exists():
|
||||
parser.error("Provide a built .app and an output ZIP path that does not already exist")
|
||||
if not re.fullmatch(r"[1-9]\d*[smh]", args.notary_timeout):
|
||||
parser.error("Use a positive notarization timeout, such as 60m")
|
||||
with signing_keychain(credentials) as (keychain, api_key, identity):
|
||||
sign(args.app.resolve(), identity, Path(__file__).with_name("direct.entitlements"), keychain=keychain)
|
||||
notarize(args.app.resolve(), args.output.resolve(), args.diagnostics.resolve(), credentials, api_key, args.notary_timeout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (RuntimeError, OSError):
|
||||
# RuntimeError messages are authored above and do not contain secret argv.
|
||||
error = sys.exc_info()[1]
|
||||
if isinstance(error, RuntimeError):
|
||||
sys.exit(f"macOS release failed: {error}")
|
||||
sys.exit("macOS release failed: file or network operation failed")
|
||||
except subprocess.CalledProcessError:
|
||||
sys.exit("macOS release failed: code signing or bundle audit failed")
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the relocated bundle with temporary settings and no IRC auto-connect."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("app", type=Path)
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix="zoitechat-macos-smoke-") as directory:
|
||||
# Include spaces to exercise dyld and GTK module-cache path handling.
|
||||
app = Path(directory) / "Relocated App" / "ZoiteChat.app"
|
||||
shutil.copytree(args.app.resolve(), app)
|
||||
binary = app / "Contents/MacOS/zoitechat"
|
||||
subprocess.run(["codesign", "--verify", "--deep", "--strict", str(app)], check=True)
|
||||
subprocess.run([str(binary), "--version"], check=True, timeout=30)
|
||||
config = Path(directory) / "test-config"
|
||||
with (Path(directory) / "startup.log").open("w+") as log:
|
||||
process = subprocess.Popen([str(binary), "--no-auto", "--cfgdir", str(config)], stdout=log, stderr=log)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Stop the isolated process after checking for early startup failure.
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise RuntimeError("GUI startup process did not terminate")
|
||||
else:
|
||||
log.seek(0)
|
||||
raise RuntimeError(f"GUI exited before smoke interval (code {process.returncode}):\n{log.read()}")
|
||||
log.seek(0)
|
||||
messages = log.read()
|
||||
print(messages)
|
||||
for failure in ("Library not loaded:", "Symbol not found:", "cannot open display", "Cannot load bundled HTTPS trust database", "Bundled emoji font is missing", "Cannot relocate", "Error loading plugin:"):
|
||||
if failure.lower() in messages.lower():
|
||||
raise RuntimeError(f"Startup failure found: {failure}")
|
||||
print("Relocated bundle stayed running for 10 seconds; manual interaction testing is still required.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>com.apple.security.app-sandbox</key><true/>
|
||||
<key>com.apple.security.network.client</key><true/>
|
||||
<!-- DCC listening sockets and the optional ident service. -->
|
||||
<key>com.apple.security.network.server</key><true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key><true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key><true/>
|
||||
</dict></plist>
|
||||
@@ -0,0 +1,172 @@
|
||||
"""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:
|
||||
directory = Path(temporary)
|
||||
library_source = directory / "answer.c"
|
||||
library_source.write_text("int answer(void) { return 42; }\n")
|
||||
library = directory / "libanswer.dylib"
|
||||
main_source = directory / "main.c"
|
||||
main_source.write_text(
|
||||
"#include <stdio.h>\n"
|
||||
"extern int answer(void);\n"
|
||||
"int main(void) { printf(\"%d\\n\", answer()); return 0; }\n"
|
||||
)
|
||||
executable = directory / "original"
|
||||
bundle.run("xcrun", "clang", "-dynamiclib", "-Wl,-headerpad_max_install_names",
|
||||
"-Wl,-install_name," + str(library), library_source, "-o", library)
|
||||
bundle.run("xcrun", "clang", "-Wl,-headerpad_max_install_names",
|
||||
"-Wl,-rpath," + str(directory), main_source, library, "-o", executable)
|
||||
for binary in (library, executable):
|
||||
bundle.run("codesign", "--force", "--sign", "-", binary)
|
||||
app = directory / "Test.app"
|
||||
target = app / "Contents/MacOS/zoitechat"
|
||||
target.parent.mkdir(parents=True)
|
||||
shutil.copy2(executable, target)
|
||||
relocator = bundle.Relocator(app, executable)
|
||||
relocator.relocate(executable, target)
|
||||
# Remove the original dependency so execution proves relocation.
|
||||
library.unlink()
|
||||
for binary in (app / "Contents/Frameworks/libanswer.dylib", target):
|
||||
bundle.run("codesign", "--force", "--sign", "-", binary)
|
||||
bundle.run("codesign", "--verify", "--strict", binary)
|
||||
self.assertEqual(bundle.run(target), "42")
|
||||
self.assertEqual(len(bundle.audit(app)["mach_o"]), 2)
|
||||
|
||||
def test_otool_preserves_paths_with_spaces(self):
|
||||
output = "app:\n\t/opt/Build Dependencies/lib/liba.dylib (compatibility version 1.0.0, current version 1.0.0)\n\t/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1.0.0)"
|
||||
with patch.object(bundle, "run", return_value=output):
|
||||
self.assertEqual(bundle.dependencies(Path("app")), ["/opt/Build Dependencies/lib/liba.dylib", "/usr/lib/libSystem.B.dylib"])
|
||||
|
||||
def test_lc_rpaths_preserve_spaces(self):
|
||||
with patch.object(bundle, "run", return_value="cmd LC_RPATH\n cmdsize 48\n path @loader_path/../Build Dependencies (offset 12)\n"):
|
||||
self.assertEqual(bundle.rpaths(Path("app")), ["@loader_path/../Build Dependencies"])
|
||||
|
||||
def test_inherited_rpath_resolution(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
library = directory / "with spaces/libtest.dylib"
|
||||
library.parent.mkdir()
|
||||
library.write_bytes(b"library")
|
||||
self.assertEqual(bundle.resolve_dependency("@rpath/libtest.dylib", directory / "libparent.dylib", directory / "app", [library.parent]), library.resolve())
|
||||
|
||||
def test_unresolved_rpath_fails(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "Cannot resolve"):
|
||||
bundle.resolve_dependency("@rpath/missing.dylib", Path("/tmp/parent"), Path("/tmp/app"), [])
|
||||
|
||||
def test_conflicting_basename_fails(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
sources = [directory / name / "libtest.dylib" for name in ("one", "two")]
|
||||
for source in sources:
|
||||
source.parent.mkdir()
|
||||
source.write_bytes(b"library")
|
||||
relocator = bundle.Relocator(directory / "App.app", directory / "executable")
|
||||
relocator.copy_dependency(sources[0])
|
||||
with self.assertRaisesRegex(RuntimeError, "Conflicting library basename"):
|
||||
relocator.copy_dependency(sources[1])
|
||||
|
||||
def test_audit_rejects_external_symlink(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
app = Path(temporary) / "App.app"
|
||||
app.mkdir()
|
||||
(app / "outside").symlink_to("/usr/lib")
|
||||
with patch.object(bundle, "run", return_value="arm64"), self.assertRaisesRegex(RuntimeError, "External symlink"):
|
||||
bundle.audit(app)
|
||||
|
||||
def test_audit_raises_minimum_for_dependencies(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
app = Path(temporary) / "App.app"
|
||||
binary = app / "Contents/MacOS/zoitechat"
|
||||
library = app / "Contents/Frameworks/libtest.dylib"
|
||||
for path in (binary, library):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(bytes.fromhex("cffaedfe") + b"payload")
|
||||
def fake_run(*args):
|
||||
if args[0] == "lipo":
|
||||
return "arm64"
|
||||
if args[1] == "-D":
|
||||
return str(args[2]) + ("\n@rpath/libtest.dylib" if args[2] == library else "")
|
||||
if args[1] == "-L":
|
||||
name = "@loader_path/../Frameworks/libtest.dylib" if args[2] == binary else "@rpath/libtest.dylib"
|
||||
return f"{args[2]}:\n\t{name} (compatibility version 1.0.0, current version 1.0.0)"
|
||||
return "cmd LC_BUILD_VERSION\n cmdsize 32\n platform 1\n minos " + ("15.0.1" if args[2] == library else "11.0")
|
||||
with patch.object(bundle, "run", side_effect=fake_run):
|
||||
report = bundle.audit(app)
|
||||
self.assertEqual(report["minimum_macos"], "15.0.1")
|
||||
self.assertEqual(report["architectures"], ["arm64"])
|
||||
self.assertEqual(len(report["mach_o"]), 2)
|
||||
|
||||
def test_audit_rejects_wrong_architecture(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
app = Path(temporary) / "App.app"
|
||||
binary = app / "Contents/MacOS/zoitechat"
|
||||
library = app / "Contents/Frameworks/libtest.dylib"
|
||||
for path in (binary, library):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(bytes.fromhex("cffaedfe"))
|
||||
with patch.object(bundle, "run", side_effect=["arm64", "x86_64"]), self.assertRaisesRegex(RuntimeError, "Architecture mismatch"):
|
||||
bundle.audit(app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Release failure gates, private-key handling and notarization result checks."""
|
||||
from contextlib import redirect_stdout
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import release
|
||||
|
||||
FINGERPRINT = "A" * 40
|
||||
IDENTITY = "Developer ID Application: Test Only (NOTAREALID)"
|
||||
SUBMISSION = "a8c9438a-b972-455e-aedc-278070349394"
|
||||
ENVIRONMENT = {
|
||||
"MACOS_CERTIFICATE_P12_BASE64": base64.b64encode(b"not-a-real-certificate").decode(),
|
||||
"MACOS_CERTIFICATE_PASSWORD": "unit-test-password",
|
||||
"MACOS_SIGNING_IDENTITY": IDENTITY,
|
||||
"APPLE_API_KEY_P8": "-----BEGIN PRIVATE KEY-----\nunit-test-only\n-----END PRIVATE KEY-----",
|
||||
"APPLE_API_KEY_ID": "TESTKEY123",
|
||||
"APPLE_API_ISSUER_ID": SUBMISSION,
|
||||
}
|
||||
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def credentials(self):
|
||||
with patch.dict(os.environ, ENVIRONMENT, clear=True):
|
||||
return release.Credentials.from_environment()
|
||||
|
||||
def test_config_reports_names_and_never_values(self):
|
||||
with patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(RuntimeError, "MACOS_CERTIFICATE_P12_BASE64"):
|
||||
release.Credentials.from_environment()
|
||||
|
||||
def test_config_accepts_multiline_certificate_base64(self):
|
||||
env = ENVIRONMENT.copy()
|
||||
text = env["MACOS_CERTIFICATE_P12_BASE64"]
|
||||
env["MACOS_CERTIFICATE_P12_BASE64"] = text[:10] + "\n" + text[10:]
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertEqual(release.Credentials.from_environment().certificate, b"not-a-real-certificate")
|
||||
|
||||
def test_config_rejects_development_certificate(self):
|
||||
env = {**ENVIRONMENT, "MACOS_SIGNING_IDENTITY": "Apple Development: Test"}
|
||||
with patch.dict(os.environ, env, clear=True), self.assertRaisesRegex(RuntimeError, "Developer ID Application"):
|
||||
release.Credentials.from_environment()
|
||||
|
||||
def test_config_rejects_base64_instead_of_plain_p8(self):
|
||||
env = {**ENVIRONMENT, "APPLE_API_KEY_P8": "not-the-pem-contents"}
|
||||
with patch.dict(os.environ, env, clear=True), self.assertRaisesRegex(RuntimeError, "complete, plain-text"):
|
||||
release.Credentials.from_environment()
|
||||
|
||||
def test_execute_does_not_expose_secret_argv_or_stderr(self):
|
||||
result = subprocess.CompletedProcess([], 1, stdout="", stderr="secret-password")
|
||||
with patch.object(release.subprocess, "run", return_value=result):
|
||||
with self.assertRaises(RuntimeError) as error:
|
||||
release.execute(["security", "import", "-P", "secret-password"], "Import certificate")
|
||||
self.assertNotIn("secret-password", str(error.exception))
|
||||
|
||||
def test_matching_identity_requires_developer_id_and_private_key(self):
|
||||
output = f' 1) {FINGERPRINT} "{IDENTITY}"\n'
|
||||
self.assertEqual(release.resolve_identity(output, IDENTITY), FINGERPRINT)
|
||||
with self.assertRaisesRegex(RuntimeError, "no valid Developer ID"):
|
||||
release.resolve_identity("0 valid identities found", IDENTITY)
|
||||
|
||||
def test_private_files_have_restricted_permissions(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "private.p8"
|
||||
release.write_private(path, b"test-only")
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_keychain_and_private_files_are_cleaned_up_after_signing_failure(self):
|
||||
calls = []
|
||||
def fake_execute(args, operation, **kwargs):
|
||||
calls.append([str(value) for value in args])
|
||||
output = ''
|
||||
if args[:3] == ["security", "list-keychains", "-d"] and len(args) == 4:
|
||||
output = '"/tmp/original.keychain-db"\n'
|
||||
if args[:2] == ["security", "create-keychain"]:
|
||||
Path(args[-1]).touch()
|
||||
if args[:2] == ["security", "find-identity"]:
|
||||
output = f' 1) {FINGERPRINT} "{IDENTITY}"\n'
|
||||
return subprocess.CompletedProcess(args, 0, output, "")
|
||||
temporary_path = None
|
||||
with patch.object(release, "execute", side_effect=fake_execute), patch.object(release.urllib.request, "urlopen", side_effect=lambda *args, **kwargs: io.BytesIO(b"public-test-ca")):
|
||||
with self.assertRaisesRegex(RuntimeError, "test signing failed"):
|
||||
with release.signing_keychain(self.credentials()) as (keychain, api_key, identity):
|
||||
temporary_path = keychain.parent
|
||||
self.assertEqual(identity, FINGERPRINT)
|
||||
self.assertTrue(api_key.is_file())
|
||||
raise RuntimeError("test signing failed")
|
||||
self.assertFalse(temporary_path.exists())
|
||||
self.assertIn(["security", "list-keychains", "-d", "user", "-s", "/tmp/original.keychain-db"], calls)
|
||||
self.assertTrue(any(call[:2] == ["security", "delete-keychain"] for call in calls))
|
||||
|
||||
def run_notary(self, status, returncode=0):
|
||||
calls = []
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
directory = Path(temporary.name)
|
||||
app = directory / "ZoiteChat.app"
|
||||
app.mkdir()
|
||||
output = directory / "release.zip"
|
||||
diagnostics = directory / "diagnostics"
|
||||
def fake_execute(args, operation, **kwargs):
|
||||
calls.append([str(value) for value in args])
|
||||
text = ""
|
||||
code = 0
|
||||
if args[:3] == ["xcrun", "notarytool", "submit"]:
|
||||
text = json.dumps({"id": SUBMISSION})
|
||||
elif args[:3] == ["xcrun", "notarytool", "wait"]:
|
||||
text = json.dumps({"id": SUBMISSION, "status": status})
|
||||
code = returncode
|
||||
elif args[:3] == ["xcrun", "notarytool", "log"]:
|
||||
Path(args[-1]).write_text('{"issues": []}')
|
||||
elif args[0] == "ditto":
|
||||
Path(args[-1]).write_bytes(b"test-zip")
|
||||
return subprocess.CompletedProcess(args, code, text, "")
|
||||
with patch.object(release, "execute", side_effect=fake_execute), redirect_stdout(io.StringIO()):
|
||||
if status == "Accepted" and not returncode:
|
||||
release.notarize(app, output, diagnostics, self.credentials(), directory / "test.p8", "60m")
|
||||
else:
|
||||
with self.assertRaisesRegex(RuntimeError, "not accepted"):
|
||||
release.notarize(app, output, diagnostics, self.credentials(), directory / "test.p8", "60m")
|
||||
return calls, output, diagnostics
|
||||
|
||||
def test_accepted_notarization_staples_and_checks_before_final_zip(self):
|
||||
calls, output, diagnostics = self.run_notary("Accepted")
|
||||
self.assertTrue(output.is_file())
|
||||
staple = next(i for i, call in enumerate(calls) if call[:3] == ["xcrun", "stapler", "staple"])
|
||||
assess = next(i for i, call in enumerate(calls) if call[0] == "spctl")
|
||||
final_zip = max(i for i, call in enumerate(calls) if call[0] == "ditto")
|
||||
self.assertLess(staple, assess)
|
||||
self.assertLess(assess, final_zip)
|
||||
self.assertEqual(json.loads((diagnostics / "status.json").read_text())["status"], "Accepted")
|
||||
|
||||
def test_rejected_notarization_never_staples_or_publishes(self):
|
||||
calls, output, diagnostics = self.run_notary("Invalid")
|
||||
self.assertFalse(output.exists())
|
||||
self.assertTrue((diagnostics / "notary-log.json").is_file())
|
||||
self.assertFalse(any(call[:2] == ["xcrun", "stapler"] for call in calls))
|
||||
|
||||
def test_timeout_retains_submission_id_and_never_publishes(self):
|
||||
calls, output, diagnostics = self.run_notary("In Progress", 1)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(json.loads((diagnostics / "submission.json").read_text())["id"], SUBMISSION)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -590,6 +590,7 @@ const struct prefs vars[] =
|
||||
{"gui_gtk3_theme", P_OFFSET (hex_gui_gtk3_theme), TYPE_STR},
|
||||
{"text_indent", P_OFFINT (hex_text_indent), TYPE_BOOL},
|
||||
{"text_max_indent", P_OFFINT (hex_text_max_indent), TYPE_INT},
|
||||
{"text_wrap_align", P_OFFINT (hex_text_wrap_align), TYPE_INT},
|
||||
{"text_max_lines", P_OFFINT (hex_text_max_lines), TYPE_INT},
|
||||
{"text_replay", P_OFFINT (hex_text_replay), TYPE_BOOL},
|
||||
{"text_search_case_match", P_OFFINT (hex_text_search_case_match), TYPE_BOOL},
|
||||
@@ -881,6 +882,7 @@ load_default_config(void)
|
||||
prefs.hex_net_reconnect_delay = 10;
|
||||
prefs.hex_notify_timeout = 15;
|
||||
prefs.hex_text_max_indent = 256;
|
||||
prefs.hex_text_wrap_align = 0;
|
||||
prefs.hex_text_max_lines = 5000;
|
||||
prefs.hex_url_grabber_limit = 100; /* 0 means unlimited */
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<ClInclude Include="typedef.h" />
|
||||
<ClInclude Include="url.h" />
|
||||
<ClInclude Include="userlist.h" />
|
||||
<ClInclude Include="hexcolor.h" />
|
||||
<ClInclude Include="util.h" />
|
||||
<ClInclude Include="zoitechat-plugin.h" />
|
||||
<ClInclude Include="zoitechat.h" />
|
||||
@@ -79,6 +80,7 @@
|
||||
<ClCompile Include="tree.c" />
|
||||
<ClCompile Include="url.c" />
|
||||
<ClCompile Include="userlist.c" />
|
||||
<ClCompile Include="hexcolor.c" />
|
||||
<ClCompile Include="util.c" />
|
||||
<ClCompile Include="zoitechat.c" />
|
||||
<ClCompile Include="theme-service.c" />
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
<ClInclude Include="userlist.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="hexcolor.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="util.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
@@ -202,6 +205,9 @@
|
||||
<ClCompile Include="userlist.c">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="hexcolor.c">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="util.c">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/* ZoiteChat
|
||||
* Parsing of \004 (hex color) formatting codes: RRGGBB[,RRGGBB].
|
||||
* Kept free of other application dependencies so the xtext widget tests can
|
||||
* compile it on its own.
|
||||
* Distributed under GPL-2.0-or-later; see COPYING. */
|
||||
|
||||
#include "hexcolor.h"
|
||||
|
||||
static int
|
||||
hexcolor_component (const char *s, int avail, guint32 *rgb)
|
||||
{
|
||||
int i;
|
||||
guint32 value = 0;
|
||||
|
||||
if (avail < 6)
|
||||
return FALSE;
|
||||
for (i = 0; i < 6; i++)
|
||||
{
|
||||
if (!g_ascii_isxdigit (s[i]))
|
||||
return FALSE;
|
||||
value = (value << 4) | g_ascii_xdigit_value (s[i]);
|
||||
}
|
||||
if (rgb)
|
||||
*rgb = value;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
int
|
||||
hexcolor_parse (const char *s, int avail, guint32 *fg, guint32 *bg, int *has_bg)
|
||||
{
|
||||
if (has_bg)
|
||||
*has_bg = FALSE;
|
||||
if (!hexcolor_component (s, avail, fg))
|
||||
return 0;
|
||||
if (avail >= 7 && s[6] == ',' && hexcolor_component (s + 7, avail - 7, bg))
|
||||
{
|
||||
if (has_bg)
|
||||
*has_bg = TRUE;
|
||||
return 13;
|
||||
}
|
||||
return 6;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* ZoiteChat
|
||||
* Parsing of \004 (hex color) formatting codes: RRGGBB[,RRGGBB].
|
||||
* See https://modern.ircdocs.horse/formatting
|
||||
* Distributed under GPL-2.0-or-later; see COPYING. */
|
||||
|
||||
#ifndef ZOITECHAT_HEXCOLOR_H
|
||||
#define ZOITECHAT_HEXCOLOR_H
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
/* Parses the parameters following a \004 byte. Returns the number of bytes
|
||||
* consumed (6 or 13), or 0 if no valid RRGGBB follows. fg, bg and has_bg
|
||||
* may be NULL. */
|
||||
int hexcolor_parse (const char *s, int avail, guint32 *fg, guint32 *bg, int *has_bg);
|
||||
|
||||
#endif
|
||||
+2
-2
@@ -271,7 +271,7 @@ ignore_read_next_entry (char *my_cfg, struct ignore *ignore)
|
||||
}
|
||||
|
||||
void
|
||||
ignore_load ()
|
||||
ignore_load (void)
|
||||
{
|
||||
struct ignore *ignore;
|
||||
struct stat st;
|
||||
@@ -302,7 +302,7 @@ ignore_load ()
|
||||
}
|
||||
|
||||
void
|
||||
ignore_save ()
|
||||
ignore_save (void)
|
||||
{
|
||||
char buf[1024];
|
||||
int fh;
|
||||
|
||||
+74
-34
@@ -1880,38 +1880,51 @@ inbound_toggle_caps (server *serv, const char *extensions_str, gboolean enable)
|
||||
char **extensions;
|
||||
gsize i;
|
||||
|
||||
if (!extensions_str)
|
||||
return;
|
||||
|
||||
extensions = g_strsplit (extensions_str, " ", 0);
|
||||
|
||||
for (i = 0; extensions[i]; i++)
|
||||
{
|
||||
const char *extension = extensions[i];
|
||||
gboolean cap_enable = enable;
|
||||
|
||||
/* ACK prefixes disabled capabilities with '-', unlike CAP DEL. */
|
||||
if (*extension == '-')
|
||||
{
|
||||
cap_enable = FALSE;
|
||||
extension++;
|
||||
}
|
||||
|
||||
if (!strcmp (extension, "solanum.chat/identify-msg"))
|
||||
serv->have_idmsg = enable;
|
||||
serv->have_idmsg = cap_enable;
|
||||
else if (!strcmp (extension, "multi-prefix"))
|
||||
serv->have_namesx = enable;
|
||||
serv->have_namesx = cap_enable;
|
||||
else if (!strcmp (extension, "account-notify"))
|
||||
serv->have_accnotify = enable;
|
||||
serv->have_accnotify = cap_enable;
|
||||
else if (!strcmp (extension, "extended-join"))
|
||||
serv->have_extjoin = enable;
|
||||
serv->have_extjoin = cap_enable;
|
||||
else if (!strcmp (extension, "userhost-in-names"))
|
||||
serv->have_uhnames = enable;
|
||||
serv->have_uhnames = cap_enable;
|
||||
else if (!strcmp (extension, "server-time")
|
||||
|| !strcmp (extension, "znc.in/server-time")
|
||||
|| !strcmp (extension, "znc.in/server-time-iso"))
|
||||
serv->have_server_time = enable;
|
||||
serv->have_server_time = cap_enable;
|
||||
else if (!strcmp (extension, "away-notify"))
|
||||
serv->have_awaynotify = enable;
|
||||
serv->have_awaynotify = cap_enable;
|
||||
else if (!strcmp (extension, "account-tag"))
|
||||
serv->have_account_tag = enable;
|
||||
serv->have_account_tag = cap_enable;
|
||||
else if (!strcmp (extension, "message-tags"))
|
||||
serv->have_message_tags = enable;
|
||||
serv->have_message_tags = cap_enable;
|
||||
else if (!strcmp (extension, "echo-message"))
|
||||
serv->have_echo_message = enable;
|
||||
serv->have_echo_message = cap_enable;
|
||||
else if (!strcmp (extension, "sasl"))
|
||||
{
|
||||
serv->have_sasl = enable;
|
||||
if (enable)
|
||||
gboolean start_auth = cap_enable && !serv->have_sasl;
|
||||
|
||||
serv->have_sasl = cap_enable;
|
||||
if (start_auth)
|
||||
{
|
||||
#ifdef USE_OPENSSL
|
||||
if (serv->loginmethod == LOGIN_SASLEXTERNAL)
|
||||
@@ -1962,10 +1975,15 @@ inbound_cap_ack (server *serv, char *nick, char *extensions,
|
||||
inbound_toggle_caps (serv, extensions, TRUE);
|
||||
}
|
||||
|
||||
static void inbound_request_new_caps (server *serv, const char *extensions_str,
|
||||
const message_tags_data *tags_data);
|
||||
|
||||
void
|
||||
inbound_cap_new (server *serv, char *nick, char *extensions,
|
||||
const message_tags_data *tags_data)
|
||||
{
|
||||
gboolean sts_upgrade_triggered = FALSE;
|
||||
|
||||
if (extensions)
|
||||
{
|
||||
char **tokens = g_strsplit (extensions, " ", 0);
|
||||
@@ -1977,7 +1995,7 @@ inbound_cap_new (server *serv, char *nick, char *extensions,
|
||||
|
||||
if (!g_strcmp0 (parts[0], "sts") && parts[1] && parts[1][0])
|
||||
{
|
||||
sts_handle_capability (serv, parts[1]);
|
||||
sts_upgrade_triggered |= sts_handle_capability (serv, parts[1]);
|
||||
}
|
||||
|
||||
g_strfreev (parts);
|
||||
@@ -1986,38 +2004,22 @@ inbound_cap_new (server *serv, char *nick, char *extensions,
|
||||
g_strfreev (tokens);
|
||||
}
|
||||
|
||||
EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPACK, serv->server_session, nick, extensions,
|
||||
EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPLIST, serv->server_session, nick, extensions,
|
||||
NULL, NULL, 0, tags_data->timestamp);
|
||||
|
||||
inbound_toggle_caps (serv, extensions, TRUE);
|
||||
/* NEW advertises availability; only ACK confirms an enabled capability. */
|
||||
if (!sts_upgrade_triggered)
|
||||
inbound_request_new_caps (serv, extensions, tags_data);
|
||||
}
|
||||
|
||||
void
|
||||
inbound_cap_del (server *serv, char *nick, char *extensions,
|
||||
const message_tags_data *tags_data)
|
||||
{
|
||||
if (extensions)
|
||||
{
|
||||
char **tokens = g_strsplit (extensions, " ", 0);
|
||||
int i;
|
||||
|
||||
for (i = 0; tokens[i]; i++)
|
||||
{
|
||||
if (!g_strcmp0 (tokens[i], "sts") ||
|
||||
g_str_has_prefix (tokens[i], "sts="))
|
||||
{
|
||||
/* STS cannot be disabled via CAP DEL. */
|
||||
g_strfreev (tokens);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
g_strfreev (tokens);
|
||||
}
|
||||
|
||||
EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPDEL, serv->server_session, nick, extensions,
|
||||
NULL, NULL, 0, tags_data->timestamp);
|
||||
|
||||
/* STS has no toggle, but must not prevent other capabilities being removed. */
|
||||
inbound_toggle_caps (serv, extensions, FALSE);
|
||||
}
|
||||
|
||||
@@ -2053,6 +2055,44 @@ static const char * const supported_caps[] = {
|
||||
"solanum.chat/identify-msg",
|
||||
};
|
||||
|
||||
static void
|
||||
inbound_request_new_caps (server *serv, const char *extensions_str,
|
||||
const message_tags_data *tags_data)
|
||||
{
|
||||
char **extensions;
|
||||
gsize i, x;
|
||||
|
||||
if (!extensions_str)
|
||||
return;
|
||||
|
||||
extensions = g_strsplit (extensions_str, " ", 0);
|
||||
for (i = 0; extensions[i]; i++)
|
||||
{
|
||||
char *value = strchr (extensions[i], '=');
|
||||
|
||||
if (value)
|
||||
*value = '\0';
|
||||
}
|
||||
|
||||
/* Request each supported capability once, keeping every IRC line bounded.
|
||||
* SASL is negotiated during login and is not in supported_caps. */
|
||||
for (x = 0; x < G_N_ELEMENTS (supported_caps); x++)
|
||||
{
|
||||
for (i = 0; extensions[i]; i++)
|
||||
{
|
||||
if (!strcmp (extensions[i], supported_caps[x]))
|
||||
{
|
||||
EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPREQ, serv->server_session,
|
||||
extensions[i], NULL, NULL, NULL, 0,
|
||||
tags_data->timestamp);
|
||||
tcp_sendf (serv, "CAP REQ :%s\r\n", extensions[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
g_strfreev (extensions);
|
||||
}
|
||||
|
||||
static int
|
||||
get_supported_mech (server *serv, const char *list)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/* Relocate resources before GTK, Fontconfig, GIO or OpenSSL initialize.
|
||||
* GPL-2.0-or-later; see COPYING. */
|
||||
#include "config.h"
|
||||
#include "macos.h"
|
||||
#include <gio/gio.h>
|
||||
#include <mach-o/dyld.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static char *locale_dir;
|
||||
|
||||
static void
|
||||
set_path (const char *variable, const char *base, const char *relative)
|
||||
{
|
||||
char *path = g_build_filename (base, relative, NULL);
|
||||
g_setenv (variable, path, TRUE);
|
||||
g_free (path);
|
||||
}
|
||||
|
||||
static void
|
||||
relocate_cache (const char *resources, const char *name, const char *variable)
|
||||
{
|
||||
char *input = g_build_filename (resources, name, NULL);
|
||||
char *contents = NULL;
|
||||
GError *error = NULL;
|
||||
|
||||
if (g_file_get_contents (input, &contents, NULL, &error))
|
||||
{
|
||||
/* GTK cache files require absolute module paths. Write only data to
|
||||
* the user cache, never into the signed/read-only application bundle. */
|
||||
char **parts = g_strsplit (contents, "@ZOITECHAT_RESOURCES@", -1);
|
||||
char *escaped = g_strescape (resources, NULL);
|
||||
char *relocated = g_strjoinv (escaped, parts);
|
||||
g_free (escaped);
|
||||
char *digest = g_compute_checksum_for_string (G_CHECKSUM_SHA256, relocated, -1);
|
||||
char *directory = g_build_filename (g_get_user_cache_dir (), "zoitechat", "modules", digest, NULL);
|
||||
char *output = g_build_filename (directory, name, NULL);
|
||||
if (g_mkdir_with_parents (directory, 0700) == 0 &&
|
||||
g_file_set_contents (output, relocated, -1, &error))
|
||||
g_setenv (variable, output, TRUE);
|
||||
else
|
||||
g_warning ("Cannot relocate %s: %s", name, error ? error->message : "cannot create cache directory");
|
||||
g_free (output);
|
||||
g_free (directory);
|
||||
g_free (digest);
|
||||
g_free (relocated);
|
||||
g_strfreev (parts);
|
||||
}
|
||||
else
|
||||
g_warning ("Cannot read %s: %s", input, error->message);
|
||||
g_clear_error (&error);
|
||||
g_free (contents);
|
||||
g_free (input);
|
||||
}
|
||||
|
||||
void
|
||||
zoitechat_macos_init (void)
|
||||
{
|
||||
uint32_t size = 0;
|
||||
char *raw, *executable, *macos, *contents, *resources, *marker;
|
||||
char *ca;
|
||||
GTlsDatabase *database;
|
||||
GError *error = NULL;
|
||||
|
||||
_NSGetExecutablePath (NULL, &size);
|
||||
raw = g_malloc (size);
|
||||
if (_NSGetExecutablePath (raw, &size) != 0)
|
||||
{
|
||||
g_free (raw);
|
||||
return;
|
||||
}
|
||||
executable = realpath (raw, NULL);
|
||||
g_free (raw);
|
||||
if (!executable)
|
||||
return;
|
||||
macos = g_path_get_dirname (executable);
|
||||
free (executable);
|
||||
contents = g_path_get_dirname (macos);
|
||||
resources = g_build_filename (contents, "Resources", NULL);
|
||||
marker = g_build_filename (resources, "zoitechat-bundle", NULL);
|
||||
g_free (macos);
|
||||
g_free (contents);
|
||||
if (!g_file_test (marker, G_FILE_TEST_IS_REGULAR))
|
||||
{
|
||||
g_free (marker);
|
||||
g_free (resources);
|
||||
return; /* Ordinary Meson installs and build-tree runs retain their paths. */
|
||||
}
|
||||
g_free (marker);
|
||||
g_setenv ("GDK_BACKEND", "quartz", TRUE);
|
||||
set_path ("ZOITECHAT_MACOS_SHARE", resources, "share");
|
||||
set_path ("XDG_DATA_DIRS", resources, "share");
|
||||
set_path ("GSETTINGS_SCHEMA_DIR", resources, "share/glib-2.0/schemas");
|
||||
set_path ("FONTCONFIG_FILE", resources, "fonts.conf");
|
||||
set_path ("GIO_EXTRA_MODULES", resources, "lib/gio/modules");
|
||||
set_path ("GIO_MODULE_DIR", resources, "lib/gio/modules");
|
||||
set_path ("ZOITECHAT_LIBDIR", resources, "lib/zoitechat/plugins");
|
||||
set_path ("ZOITECHAT_EMOJI_FONT", resources, "share/fonts/zoitechat/NotoColorEmoji.ttf");
|
||||
set_path ("ZOITECHAT_ENCHANT_LIBRARY", resources, "lib/libenchant-2.dylib");
|
||||
g_setenv ("ZOITECHAT_ENCHANT_PREFIX", resources, TRUE);
|
||||
set_path ("SSL_CERT_FILE", resources, "certs/ca-bundle.crt");
|
||||
set_path ("OPENSSL_MODULES", resources, "lib/ossl-modules");
|
||||
locale_dir = g_build_filename (resources, "share/locale", NULL);
|
||||
relocate_cache (resources, "pixbuf-loaders.cache", "GDK_PIXBUF_MODULE_FILE");
|
||||
relocate_cache (resources, "gtk-immodules.cache", "GTK_IM_MODULE_FILE");
|
||||
|
||||
/* HTTPS previews use GIO; IRC uses OpenSSL. Give both the same CA set. */
|
||||
ca = g_build_filename (resources, "certs/ca-bundle.crt", NULL);
|
||||
database = g_tls_file_database_new (ca, &error);
|
||||
if (database)
|
||||
{
|
||||
g_tls_backend_set_default_database (g_tls_backend_get_default (), database);
|
||||
g_object_unref (database);
|
||||
}
|
||||
else
|
||||
g_warning ("Cannot load bundled HTTPS trust database: %s", error->message);
|
||||
g_clear_error (&error);
|
||||
g_free (ca);
|
||||
g_free (resources);
|
||||
}
|
||||
|
||||
const char *
|
||||
zoitechat_macos_locale_dir (void)
|
||||
{
|
||||
return locale_dir ? locale_dir : LOCALEDIR;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* macOS bundle runtime; GPL-2.0-or-later, see COPYING. */
|
||||
#ifndef ZOITECHAT_MACOS_H
|
||||
#define ZOITECHAT_MACOS_H
|
||||
void zoitechat_macos_init (void);
|
||||
const char *zoitechat_macos_locale_dir (void);
|
||||
#endif
|
||||
+29
-2
@@ -25,9 +25,14 @@ common_sources = [
|
||||
'tree.c',
|
||||
'url.c',
|
||||
'userlist.c',
|
||||
'hexcolor.c',
|
||||
'util.c'
|
||||
]
|
||||
|
||||
if host_machine.system() == 'darwin'
|
||||
common_sources += 'macos.c'
|
||||
endif
|
||||
|
||||
secretstore_sources = files('secretstore.c')
|
||||
|
||||
common_sysinfo_deps = []
|
||||
@@ -154,12 +159,19 @@ zoitechat_common = static_library('zoitechatcommon',
|
||||
pic: true
|
||||
)
|
||||
|
||||
# zoitechat.h exposes OpenSSL types whenever TLS is enabled. Propagate its
|
||||
# header requirements even to standalone consumers that do not call OpenSSL.
|
||||
zoitechat_common_header_deps = [libgio_dep]
|
||||
if libssl_dep.found()
|
||||
zoitechat_common_header_deps += libssl_dep.partial_dependency(compile_args: true, includes: true)
|
||||
endif
|
||||
|
||||
zoitechat_common_dep = declare_dependency(
|
||||
sources: [textevents, public_suffix_data] + marshal,
|
||||
link_with: zoitechat_common,
|
||||
include_directories: common_includes,
|
||||
compile_args: common_cflags,
|
||||
dependencies: libgio_dep,
|
||||
dependencies: zoitechat_common_header_deps,
|
||||
)
|
||||
|
||||
zoitechat_plugin_dep = declare_dependency(
|
||||
@@ -175,7 +187,7 @@ gtk3_theme_service_tests = executable('gtk3_theme_service_tests',
|
||||
'gtk3-theme-service.c',
|
||||
],
|
||||
include_directories: [config_h_include, include_directories('.')],
|
||||
dependencies: [libgio_dep] + (libarchive_dep.found() ? [libarchive_dep] : []),
|
||||
dependencies: zoitechat_common_header_deps + (libarchive_dep.found() ? [libarchive_dep] : []),
|
||||
)
|
||||
|
||||
test('GTK3 Theme Service Tests', gtk3_theme_service_tests,
|
||||
@@ -197,3 +209,18 @@ test('Emoji Data Tests', emoji_data_tests,
|
||||
protocol: 'tap',
|
||||
timeout: 120,
|
||||
)
|
||||
|
||||
hexcolor_tests = executable('hexcolor_tests',
|
||||
[
|
||||
'tests/test-hexcolor.c',
|
||||
'hexcolor.c',
|
||||
'util.c',
|
||||
],
|
||||
include_directories: [config_h_include, include_directories('.')],
|
||||
dependencies: [libgio_dep] + (libssl_dep.found() ? [libssl_dep] : []),
|
||||
)
|
||||
|
||||
test('Hex Color Tests', hexcolor_tests,
|
||||
protocol: 'tap',
|
||||
timeout: 120,
|
||||
)
|
||||
+1
-1
@@ -662,7 +662,7 @@ notify_isnotify (struct session *sess, char *name)
|
||||
}
|
||||
|
||||
void
|
||||
notify_cleanup ()
|
||||
notify_cleanup (void)
|
||||
{
|
||||
GSList *list = notify_list;
|
||||
GSList *nslist, *srvlist;
|
||||
|
||||
@@ -1288,6 +1288,11 @@ process_named_msg (session *sess, char *type, char *word[], char *word_eol[],
|
||||
char *user = net && net->user ? net->user : prefs.hex_irc_user_name;
|
||||
|
||||
response = challengeauth_response (user, serv->password, word[5]);
|
||||
if (response == NULL)
|
||||
{
|
||||
PrintTextf (serv->server_session, _("Error building CHALLENGEAUTH for %s"), user);
|
||||
return;
|
||||
}
|
||||
|
||||
tcp_sendf (serv, "PRIVMSG %s :CHALLENGEAUTH %s %s %s\r\n",
|
||||
CHALLENGEAUTH_NICK,
|
||||
|
||||
+3
-3
@@ -155,7 +155,7 @@ _SSL_get_cert_info (struct cert_info *cert_info, SSL * ssl)
|
||||
{
|
||||
X509 *peer_cert;
|
||||
const X509_PUBKEY *key;
|
||||
X509_ALGOR *algor = NULL;
|
||||
ASN1_OBJECT *obj = NULL;
|
||||
EVP_PKEY *peer_pkey;
|
||||
char notBefore[64];
|
||||
char notAfter[64];
|
||||
@@ -174,10 +174,10 @@ _SSL_get_cert_info (struct cert_info *cert_info, SSL * ssl)
|
||||
broke_oneline (cert_info->issuer, cert_info->issuer_word);
|
||||
|
||||
key = X509_get_X509_PUBKEY(peer_cert);
|
||||
if (!X509_PUBKEY_get0_param(NULL, NULL, 0, &algor, key))
|
||||
if (!X509_PUBKEY_get0_param(&obj, NULL, NULL, NULL, key))
|
||||
return 1;
|
||||
|
||||
alg = OBJ_obj2nid (algor->algorithm);
|
||||
alg = OBJ_obj2nid (obj);
|
||||
#ifndef HAVE_X509_GET_SIGNATURE_NID
|
||||
sign_alg = OBJ_obj2nid (peer_cert->sig_alg->algorithm);
|
||||
#else
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/* ZoiteChat
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include <glib.h>
|
||||
|
||||
#include "../hexcolor.h"
|
||||
#include "../util.h"
|
||||
|
||||
static void
|
||||
test_parse_foreground (void)
|
||||
{
|
||||
guint32 fg = 0, bg = 0xDEAD;
|
||||
int has_bg = TRUE;
|
||||
|
||||
g_assert_cmpint (hexcolor_parse ("FF8800text", 10, &fg, &bg, &has_bg), ==, 6);
|
||||
g_assert_cmphex (fg, ==, 0xFF8800);
|
||||
g_assert_false (has_bg);
|
||||
g_assert_cmphex (bg, ==, 0xDEAD);
|
||||
}
|
||||
|
||||
static void
|
||||
test_parse_foreground_and_background (void)
|
||||
{
|
||||
guint32 fg = 0, bg = 0;
|
||||
int has_bg = FALSE;
|
||||
|
||||
g_assert_cmpint (hexcolor_parse ("112233,a0b0c0x", 14, &fg, &bg, &has_bg), ==, 13);
|
||||
g_assert_cmphex (fg, ==, 0x112233);
|
||||
g_assert_cmphex (bg, ==, 0xA0B0C0);
|
||||
g_assert_true (has_bg);
|
||||
}
|
||||
|
||||
static void
|
||||
test_parse_bad_background_keeps_foreground (void)
|
||||
{
|
||||
guint32 fg = 0;
|
||||
int has_bg = TRUE;
|
||||
|
||||
/* ",12" is plain text after a valid foreground, not a background */
|
||||
g_assert_cmpint (hexcolor_parse ("112233,12 hi", 12, &fg, NULL, &has_bg), ==, 6);
|
||||
g_assert_cmphex (fg, ==, 0x112233);
|
||||
g_assert_false (has_bg);
|
||||
}
|
||||
|
||||
static void
|
||||
test_parse_bare (void)
|
||||
{
|
||||
/* a bare \004 resets colors, like a bare \003 */
|
||||
g_assert_cmpint (hexcolor_parse (" text", 5, NULL, NULL, NULL), ==, 0);
|
||||
g_assert_cmpint (hexcolor_parse ("12345G", 6, NULL, NULL, NULL), ==, 0);
|
||||
g_assert_cmpint (hexcolor_parse ("", 0, NULL, NULL, NULL), ==, 0);
|
||||
}
|
||||
|
||||
static void
|
||||
test_parse_respects_length (void)
|
||||
{
|
||||
g_assert_cmpint (hexcolor_parse ("FF8800", 5, NULL, NULL, NULL), ==, 0);
|
||||
g_assert_cmpint (hexcolor_parse ("FF8800,112233", 12, NULL, NULL, NULL), ==, 6);
|
||||
}
|
||||
|
||||
static void
|
||||
assert_stripped (const char *in, int flags, const char *expected)
|
||||
{
|
||||
char out[128];
|
||||
|
||||
strip_color2 (in, -1, out, flags);
|
||||
g_assert_cmpstr (out, ==, expected);
|
||||
}
|
||||
|
||||
static void
|
||||
test_strip (void)
|
||||
{
|
||||
assert_stripped ("\004FF8800alice\017: hi", STRIP_ALL, "alice: hi");
|
||||
assert_stripped ("\004112233,445566bg", STRIP_ALL, "bg");
|
||||
assert_stripped ("a\004 b", STRIP_ALL, "a b");
|
||||
assert_stripped ("\004FF8800\00312x", STRIP_ALL, "x");
|
||||
/* a trailing code with too few digits leaves the rest as text */
|
||||
assert_stripped ("x\004FF88", STRIP_ALL, "xFF88");
|
||||
}
|
||||
|
||||
static void
|
||||
test_strip_keeps_hex_without_strip_color (void)
|
||||
{
|
||||
assert_stripped ("\004FF8800red\002", STRIP_ATTRIB, "\004FF8800red");
|
||||
}
|
||||
|
||||
int
|
||||
main (int argc, char *argv[])
|
||||
{
|
||||
g_test_init (&argc, &argv, NULL);
|
||||
|
||||
g_test_add_func ("/hexcolor/parse/foreground", test_parse_foreground);
|
||||
g_test_add_func ("/hexcolor/parse/foreground-and-background", test_parse_foreground_and_background);
|
||||
g_test_add_func ("/hexcolor/parse/bad-background", test_parse_bad_background_keeps_foreground);
|
||||
g_test_add_func ("/hexcolor/parse/bare", test_parse_bare);
|
||||
g_test_add_func ("/hexcolor/parse/length", test_parse_respects_length);
|
||||
g_test_add_func ("/hexcolor/strip/all", test_strip);
|
||||
g_test_add_func ("/hexcolor/strip/attrib-only", test_strip_keeps_hex_without_strip_color);
|
||||
|
||||
return g_test_run ();
|
||||
}
|
||||
+51
-25
@@ -68,7 +68,7 @@ static ca_context *ca_con;
|
||||
#define SCROLLBACK_MAX 32000
|
||||
|
||||
static void mkdir_p (char *filename);
|
||||
static char *log_create_filename (char *channame);
|
||||
static char *log_create_filename (const char *channame);
|
||||
|
||||
static char *
|
||||
scrollback_get_filename (session *sess)
|
||||
@@ -396,7 +396,7 @@ mkdir_p (char *filename)
|
||||
}
|
||||
|
||||
static char *
|
||||
log_create_filename (char *channame)
|
||||
log_create_filename (const char *channame)
|
||||
{
|
||||
char *tmp, *ret;
|
||||
int mbl;
|
||||
@@ -453,7 +453,7 @@ log_escape_strcpy (char *dest, char *src, char *end)
|
||||
/* substitutes %c %n %s into buffer */
|
||||
|
||||
static void
|
||||
log_insert_vars (char *buf, int bufsize, char *fmt, char *c, char *n, char *s)
|
||||
log_insert_vars (char *buf, int bufsize, const char *fmt, char *c, char *n, char *s)
|
||||
{
|
||||
char *end = buf + bufsize;
|
||||
|
||||
@@ -466,6 +466,14 @@ log_insert_vars (char *buf, int bufsize, char *fmt, char *c, char *n, char *s)
|
||||
return;
|
||||
|
||||
case '%':
|
||||
/* A partially typed custom format can end in a literal percent. */
|
||||
if (!fmt[1])
|
||||
{
|
||||
buf[0] = '%';
|
||||
if (buf + 1 < end) buf[1] = 0;
|
||||
else buf[0] = 0;
|
||||
return;
|
||||
}
|
||||
fmt++;
|
||||
switch (fmt[0])
|
||||
{
|
||||
@@ -479,6 +487,11 @@ log_insert_vars (char *buf, int bufsize, char *fmt, char *c, char *n, char *s)
|
||||
buf = log_escape_strcpy (buf, s, end);
|
||||
break;
|
||||
default:
|
||||
if (buf + 1 == end)
|
||||
{
|
||||
buf[0] = 0;
|
||||
return;
|
||||
}
|
||||
buf[0] = '%';
|
||||
buf++;
|
||||
buf[0] = fmt[0];
|
||||
@@ -500,61 +513,74 @@ log_insert_vars (char *buf, int bufsize, char *fmt, char *c, char *n, char *s)
|
||||
}
|
||||
}
|
||||
|
||||
static char *
|
||||
log_create_pathname (char *servname, char *channame, char *netname)
|
||||
char *
|
||||
log_build_pathname (const char *mask, const char *config_dir,
|
||||
const char *servname, const char *channame,
|
||||
const char *netname, time_t now)
|
||||
{
|
||||
char fname[384];
|
||||
char fnametime[384];
|
||||
time_t now;
|
||||
char *safe_channel, *safe_network, *safe_server;
|
||||
|
||||
if (!mask || !*mask || !config_dir || !servname || !channame ||
|
||||
!g_utf8_validate (mask, -1, NULL))
|
||||
return NULL;
|
||||
|
||||
if (!netname)
|
||||
{
|
||||
netname = g_strdup ("NETWORK");
|
||||
safe_network = g_strdup ("NETWORK");
|
||||
}
|
||||
else
|
||||
{
|
||||
netname = log_create_filename (netname);
|
||||
safe_network = log_create_filename (netname);
|
||||
}
|
||||
|
||||
/* first, everything is in UTF-8 */
|
||||
if (!rfc_casecmp (channame, servname))
|
||||
{
|
||||
channame = g_strdup ("server");
|
||||
safe_channel = g_strdup ("server");
|
||||
}
|
||||
else
|
||||
{
|
||||
channame = log_create_filename (channame);
|
||||
safe_channel = log_create_filename (channame);
|
||||
}
|
||||
|
||||
servname = log_create_filename (servname);
|
||||
safe_server = log_create_filename (servname);
|
||||
|
||||
log_insert_vars (fname, sizeof (fname), prefs.hex_irc_logmask, channame, netname, servname);
|
||||
g_free (channame);
|
||||
g_free (netname);
|
||||
g_free (servname);
|
||||
log_insert_vars (fname, sizeof (fname), mask, safe_channel, safe_network, safe_server);
|
||||
g_free (safe_channel);
|
||||
g_free (safe_network);
|
||||
g_free (safe_server);
|
||||
|
||||
/* insert time/date */
|
||||
now = time (NULL);
|
||||
strftime_utf8 (fnametime, sizeof (fnametime), fname, now);
|
||||
if (!g_utf8_validate (fname, -1, NULL) ||
|
||||
!strftime_utf8 (fnametime, sizeof (fnametime), fname, now))
|
||||
return NULL;
|
||||
|
||||
/* If one uses log mask variables, such as "%c/...", %c will be empty upon
|
||||
* connecting since there's no channel name yet, so we have to make sure
|
||||
* we won't try to write to the FS root. */
|
||||
if (g_path_is_absolute (prefs.hex_irc_logmask))
|
||||
if (g_path_is_absolute (mask))
|
||||
{
|
||||
g_snprintf (fname, sizeof (fname), "%s", fnametime);
|
||||
}
|
||||
else /* relative path */
|
||||
{
|
||||
g_snprintf (fname, sizeof (fname), "%s" G_DIR_SEPARATOR_S "logs" G_DIR_SEPARATOR_S "%s", get_xdir (), fnametime);
|
||||
g_snprintf (fname, sizeof (fname), "%s" G_DIR_SEPARATOR_S "logs" G_DIR_SEPARATOR_S "%s", config_dir, fnametime);
|
||||
}
|
||||
|
||||
/* create all the subdirectories */
|
||||
mkdir_p (fname);
|
||||
|
||||
return g_strdup (fname);
|
||||
}
|
||||
|
||||
static char *
|
||||
log_create_pathname (char *servname, char *channame, char *netname)
|
||||
{
|
||||
char *filename = log_build_pathname (prefs.hex_irc_logmask, get_xdir (),
|
||||
servname, channame, netname, time (NULL));
|
||||
if (filename) mkdir_p (filename);
|
||||
return filename;
|
||||
}
|
||||
|
||||
static int
|
||||
log_open_file (char *servname, char *channame, char *netname)
|
||||
{
|
||||
@@ -1808,7 +1834,7 @@ pevent_check_all_loaded (void)
|
||||
}
|
||||
|
||||
void
|
||||
load_text_events ()
|
||||
load_text_events (void)
|
||||
{
|
||||
memset (&pntevts_text, 0, sizeof (char *) * (NUM_XP));
|
||||
memset (&pntevts, 0, sizeof (char *) * (NUM_XP));
|
||||
@@ -2488,7 +2514,7 @@ sound_load_event (char *evt, char *file)
|
||||
}
|
||||
|
||||
void
|
||||
sound_load ()
|
||||
sound_load (void)
|
||||
{
|
||||
int fd;
|
||||
char buf[512];
|
||||
@@ -2521,7 +2547,7 @@ sound_load ()
|
||||
}
|
||||
|
||||
void
|
||||
sound_save ()
|
||||
sound_save (void)
|
||||
{
|
||||
int fd, i;
|
||||
char buf[512];
|
||||
|
||||
@@ -47,6 +47,11 @@ void PrintTextf (session *sess, const char *format, ...) G_GNUC_PRINTF (2, 3);
|
||||
void PrintTextTimeStampf (session *sess, time_t timestamp, const char *format, ...) G_GNUC_PRINTF (3, 4);
|
||||
void log_close (session *sess);
|
||||
void log_open_or_close (session *sess);
|
||||
/* Resolve a log filename without creating directories or changing preferences.
|
||||
* The caller owns the UTF-8 result; NULL means the format could not be expanded. */
|
||||
char *log_build_pathname (const char *mask, const char *config_dir,
|
||||
const char *servname, const char *channame,
|
||||
const char *netname, time_t now);
|
||||
void load_text_events (void);
|
||||
void pevent_save (char *fn);
|
||||
int pevt_build_string (const char *input, char **output, int *max_arg);
|
||||
|
||||
+16
-2
@@ -47,6 +47,7 @@
|
||||
#include "zoitechatc.h"
|
||||
#include <ctype.h>
|
||||
#include "util.h"
|
||||
#include "hexcolor.h"
|
||||
|
||||
#if defined (__FreeBSD__) || defined (__APPLE__)
|
||||
#include <sys/sysctl.h>
|
||||
@@ -330,6 +331,14 @@ strip_color2 (const char *src, int len, char *dst, int flags)
|
||||
if (!(flags & STRIP_COLOR)) goto pass_char;
|
||||
rcol = 2;
|
||||
break;
|
||||
case '\004': /*ATTR_HEXCOLOR: */
|
||||
if (!(flags & STRIP_COLOR)) goto pass_char;
|
||||
{
|
||||
int skip = hexcolor_parse (src + 1, len, NULL, NULL, NULL);
|
||||
src += skip;
|
||||
len -= skip;
|
||||
}
|
||||
break;
|
||||
case HIDDEN_CHAR: /* CL: invisible text (for event formats only) */ /* this takes care of the topic */
|
||||
if (!(flags & STRIP_HIDDEN)) goto pass_char;
|
||||
break;
|
||||
@@ -1444,7 +1453,7 @@ challengeauth_response (const char *username, const char *password, const char *
|
||||
char *passhash;
|
||||
char *key;
|
||||
char *keyhash;
|
||||
unsigned char *digest;
|
||||
unsigned char digest[SHA256_DIGEST_LENGTH];
|
||||
GString *buf = g_string_new_len (NULL, SHA256_DIGEST_LENGTH * 2);
|
||||
|
||||
user = rfc_strlower (username); /* convert username to lowercase as per the RFC */
|
||||
@@ -1460,7 +1469,12 @@ challengeauth_response (const char *username, const char *password, const char *
|
||||
keyhash = str_sha256hash (key);
|
||||
g_free (key);
|
||||
|
||||
digest = HMAC (EVP_sha256 (), keyhash, strlen (keyhash), (unsigned char *) challenge, strlen (challenge), NULL, NULL);
|
||||
if (HMAC (EVP_sha256 (), keyhash, strlen (keyhash), (unsigned char *) challenge, strlen (challenge), digest, NULL) == NULL)
|
||||
{
|
||||
g_free (keyhash);
|
||||
g_string_free (buf, TRUE);
|
||||
return NULL;
|
||||
}
|
||||
g_free (keyhash);
|
||||
|
||||
for (i = 0; i < SHA256_DIGEST_LENGTH; i++)
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
#include "text.h"
|
||||
#include "url.h"
|
||||
#include "zoitechatc.h"
|
||||
#ifdef __APPLE__
|
||||
#include "macos.h"
|
||||
#endif
|
||||
|
||||
#if ! GLIB_CHECK_VERSION (2, 36, 0)
|
||||
#include <glib-object.h> /* for g_type_init() */
|
||||
@@ -1172,6 +1175,10 @@ main (int argc, char *argv[])
|
||||
int i;
|
||||
int ret;
|
||||
|
||||
#ifdef __APPLE__
|
||||
zoitechat_macos_init ();
|
||||
#endif
|
||||
|
||||
#ifdef WIN32
|
||||
HRESULT coinit_result;
|
||||
#endif
|
||||
|
||||
@@ -306,6 +306,7 @@ struct zoitechatprefs
|
||||
int hex_net_reconnect_delay;
|
||||
int hex_notify_timeout;
|
||||
int hex_text_max_indent;
|
||||
int hex_text_wrap_align;
|
||||
int hex_text_max_lines;
|
||||
int hex_url_grabber_limit;
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
#include "gtkutil.h"
|
||||
#include "maingui.h"
|
||||
#include "editlist.h"
|
||||
#include "preferences-persistence.h"
|
||||
#include "setup.h"
|
||||
|
||||
#define ICON_EDITLIST_NEW "document-new"
|
||||
#define ICON_EDITLIST_DELETE "edit-delete"
|
||||
@@ -373,3 +375,301 @@ editlist_gui_open (char *title1, char *title2, GSList *list, char *title, char *
|
||||
|
||||
gtk_widget_show (editlist_win);
|
||||
}
|
||||
|
||||
/* Preferences editors are independent instances: several lists may be open
|
||||
* at once, and their models must never share the legacy dialog globals. */
|
||||
typedef struct
|
||||
{
|
||||
GtkWidget *view;
|
||||
GtkWidget *delete_button;
|
||||
GtkWidget *up_button;
|
||||
GtkWidget *down_button;
|
||||
GSList **target;
|
||||
const char *file;
|
||||
char *original;
|
||||
char *pending;
|
||||
} PreferencesList;
|
||||
|
||||
static GSList *preferences_lists;
|
||||
|
||||
static void
|
||||
preferences_list_update_buttons (PreferencesList *editor)
|
||||
{
|
||||
GtkTreeModel *model;
|
||||
GtkTreeIter iter, next;
|
||||
GtkTreePath *path;
|
||||
gboolean selected = gtk_tree_selection_get_selected (
|
||||
gtk_tree_view_get_selection (GTK_TREE_VIEW (editor->view)), &model, &iter);
|
||||
gboolean up = FALSE, down = FALSE;
|
||||
if (selected)
|
||||
{
|
||||
path = gtk_tree_model_get_path (model, &iter);
|
||||
up = gtk_tree_path_get_indices (path)[0] > 0;
|
||||
gtk_tree_path_free (path);
|
||||
next = iter;
|
||||
down = gtk_tree_model_iter_next (model, &next);
|
||||
}
|
||||
gtk_widget_set_sensitive (editor->delete_button, selected);
|
||||
gtk_widget_set_sensitive (editor->up_button, up);
|
||||
gtk_widget_set_sensitive (editor->down_button, down);
|
||||
}
|
||||
|
||||
static void
|
||||
preferences_list_selection_changed (GtkTreeSelection *selection, PreferencesList *editor)
|
||||
{
|
||||
preferences_list_update_buttons (editor);
|
||||
}
|
||||
|
||||
static void
|
||||
preferences_list_rows_reordered (GtkTreeModel *model, GtkTreePath *path, GtkTreeIter *iter,
|
||||
gint *order, PreferencesList *editor)
|
||||
{
|
||||
preferences_list_update_buttons (editor);
|
||||
}
|
||||
|
||||
static char *
|
||||
preferences_list_serialize (GSList *list)
|
||||
{
|
||||
GString *text = g_string_new ("");
|
||||
for (; list; list = list->next)
|
||||
{
|
||||
struct popup *item = list->data;
|
||||
g_string_append_printf (text, "NAME %s\nCMD %s\n\n", item->name, item->cmd);
|
||||
}
|
||||
return g_string_free (text, FALSE);
|
||||
}
|
||||
|
||||
static void
|
||||
preferences_list_free (gpointer data)
|
||||
{
|
||||
PreferencesList *editor = data;
|
||||
preferences_lists = g_slist_remove (preferences_lists, editor);
|
||||
g_free (editor->original);
|
||||
g_free (editor->pending);
|
||||
g_free (editor);
|
||||
}
|
||||
|
||||
static void
|
||||
preferences_list_edited (GtkCellRenderer *renderer, char *path, char *text, PreferencesList *editor)
|
||||
{
|
||||
GtkTreeIter iter;
|
||||
GtkTreeModel *model = gtk_tree_view_get_model (GTK_TREE_VIEW (editor->view));
|
||||
if (gtk_tree_model_get_iter_from_string (model, &iter, path))
|
||||
gtk_list_store_set (GTK_LIST_STORE (model), &iter,
|
||||
GPOINTER_TO_INT (g_object_get_data (G_OBJECT (renderer), "column")), text, -1);
|
||||
}
|
||||
|
||||
static void
|
||||
preferences_list_add (GtkButton *button, PreferencesList *editor)
|
||||
{
|
||||
GtkTreeIter iter;
|
||||
GtkTreePath *path;
|
||||
GtkTreeView *view = GTK_TREE_VIEW (editor->view);
|
||||
GtkListStore *store = GTK_LIST_STORE (gtk_tree_view_get_model (view));
|
||||
(void)button;
|
||||
gtk_list_store_insert_with_values (store, &iter, -1, NAME_COLUMN, "", CMD_COLUMN, "", -1);
|
||||
path = gtk_tree_model_get_path (GTK_TREE_MODEL (store), &iter);
|
||||
gtk_tree_view_set_cursor (view, path, gtk_tree_view_get_column (view, 0), TRUE);
|
||||
gtk_tree_view_scroll_to_cell (view, path, NULL, FALSE, 0, 0);
|
||||
gtk_tree_path_free (path);
|
||||
preferences_list_update_buttons (editor);
|
||||
}
|
||||
|
||||
static void
|
||||
preferences_list_delete (GtkButton *button, PreferencesList *editor)
|
||||
{
|
||||
GtkTreeIter iter;
|
||||
GtkTreeModel *model;
|
||||
(void)button;
|
||||
if (gtk_tree_selection_get_selected (gtk_tree_view_get_selection (GTK_TREE_VIEW (editor->view)), &model, &iter))
|
||||
gtk_list_store_remove (GTK_LIST_STORE (model), &iter);
|
||||
}
|
||||
|
||||
static void
|
||||
preferences_list_move (GtkButton *button, PreferencesList *editor)
|
||||
{
|
||||
GtkTreeModel *model;
|
||||
GtkTreeIter iter, other;
|
||||
GtkTreePath *path;
|
||||
gboolean valid;
|
||||
if (!gtk_tree_selection_get_selected (gtk_tree_view_get_selection (GTK_TREE_VIEW (editor->view)), &model, &iter))
|
||||
return;
|
||||
path = gtk_tree_model_get_path (model, &iter);
|
||||
if (GPOINTER_TO_INT (g_object_get_data (G_OBJECT (button), "direction")) < 0)
|
||||
valid = gtk_tree_path_prev (path);
|
||||
else { gtk_tree_path_next (path); valid = TRUE; }
|
||||
if (valid && gtk_tree_model_get_iter (model, &other, path))
|
||||
{
|
||||
gtk_list_store_swap (GTK_LIST_STORE (model), &iter, &other);
|
||||
gtk_tree_view_scroll_to_cell (GTK_TREE_VIEW (editor->view), path, NULL, FALSE, 0, 0);
|
||||
}
|
||||
gtk_tree_path_free (path);
|
||||
}
|
||||
|
||||
static gboolean
|
||||
preferences_list_key (GtkWidget *view, GdkEventKey *event, PreferencesList *editor)
|
||||
{
|
||||
GtkWidget *button;
|
||||
int direction;
|
||||
(void)view;
|
||||
if (!(event->state & GDK_SHIFT_MASK) || (event->keyval != GDK_KEY_Up && event->keyval != GDK_KEY_Down))
|
||||
return FALSE;
|
||||
direction = event->keyval == GDK_KEY_Up ? -1 : 1;
|
||||
button = gtk_button_new ();
|
||||
g_object_ref_sink (button);
|
||||
g_object_set_data (G_OBJECT (button), "direction", GINT_TO_POINTER (direction));
|
||||
preferences_list_move (GTK_BUTTON (button), editor);
|
||||
g_object_unref (button);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
GtkWidget *
|
||||
editlist_preferences_create (GSList **list, const char *file, const char *title1,
|
||||
const char *title2, const char *help)
|
||||
{
|
||||
PreferencesList *editor = g_new0 (PreferencesList, 1);
|
||||
GtkWidget *box, *scroll, *buttons, *button;
|
||||
GtkListStore *store;
|
||||
GtkCellRenderer *renderer;
|
||||
int column;
|
||||
box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 10);
|
||||
editor->target = list;
|
||||
editor->file = file;
|
||||
editor->original = preferences_list_serialize (*list);
|
||||
preferences_lists = g_slist_append (preferences_lists, editor);
|
||||
g_object_set_data_full (G_OBJECT (box), "preferences-list", editor, preferences_list_free);
|
||||
store = gtk_list_store_new (N_COLUMNS, G_TYPE_STRING, G_TYPE_STRING);
|
||||
editor->view = gtk_tree_view_new_with_model (GTK_TREE_MODEL (store));
|
||||
gtk_tree_view_set_reorderable (GTK_TREE_VIEW (editor->view), TRUE);
|
||||
gtk_tree_view_set_search_column (GTK_TREE_VIEW (editor->view), NAME_COLUMN);
|
||||
for (column = 0; column < N_COLUMNS; column++)
|
||||
{
|
||||
GtkTreeViewColumn *col;
|
||||
renderer = gtk_cell_renderer_text_new ();
|
||||
g_object_set (renderer, "editable", TRUE, NULL);
|
||||
g_object_set_data (G_OBJECT (renderer), "column", GINT_TO_POINTER (column));
|
||||
g_signal_connect (renderer, "edited", G_CALLBACK (preferences_list_edited), editor);
|
||||
col = gtk_tree_view_column_new_with_attributes (column ? title2 : title1, renderer, "text", column, NULL);
|
||||
gtk_tree_view_column_set_resizable (col, TRUE);
|
||||
gtk_tree_view_column_set_min_width (col, column ? 240 : 140);
|
||||
gtk_tree_view_column_set_expand (col, column != 0);
|
||||
gtk_tree_view_append_column (GTK_TREE_VIEW (editor->view), col);
|
||||
}
|
||||
editlist_load (store, *list);
|
||||
g_object_unref (store);
|
||||
g_signal_connect (editor->view, "key-press-event", G_CALLBACK (preferences_list_key), editor);
|
||||
scroll = gtk_scrolled_window_new (NULL, NULL);
|
||||
gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC);
|
||||
gtk_scrolled_window_set_min_content_height (GTK_SCROLLED_WINDOW (scroll), 220);
|
||||
gtk_container_add (GTK_CONTAINER (scroll), editor->view);
|
||||
gtk_box_pack_start (GTK_BOX (box), scroll, TRUE, TRUE, 0);
|
||||
buttons = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 8);
|
||||
gtk_box_pack_start (GTK_BOX (box), buttons, FALSE, FALSE, 0);
|
||||
button = gtk_button_new_with_mnemonic (_("_Add"));
|
||||
g_signal_connect (button, "clicked", G_CALLBACK (preferences_list_add), editor);
|
||||
gtk_box_pack_start (GTK_BOX (buttons), button, FALSE, FALSE, 0);
|
||||
button = gtk_button_new_with_mnemonic (_("_Delete"));
|
||||
editor->delete_button = button;
|
||||
g_signal_connect (button, "clicked", G_CALLBACK (preferences_list_delete), editor);
|
||||
gtk_box_pack_start (GTK_BOX (buttons), button, FALSE, FALSE, 0);
|
||||
for (column = -1; column <= 1; column += 2)
|
||||
{
|
||||
button = gtk_button_new_with_label (column < 0 ? _("Move up") : _("Move down"));
|
||||
if (column < 0) editor->up_button = button;
|
||||
else editor->down_button = button;
|
||||
g_object_set_data (G_OBJECT (button), "direction", GINT_TO_POINTER (column));
|
||||
g_signal_connect (button, "clicked", G_CALLBACK (preferences_list_move), editor);
|
||||
gtk_box_pack_start (GTK_BOX (buttons), button, FALSE, FALSE, 0);
|
||||
}
|
||||
g_signal_connect (gtk_tree_view_get_selection (GTK_TREE_VIEW (editor->view)), "changed",
|
||||
G_CALLBACK (preferences_list_selection_changed), editor);
|
||||
g_signal_connect (gtk_tree_view_get_model (GTK_TREE_VIEW (editor->view)), "rows-reordered",
|
||||
G_CALLBACK (preferences_list_rows_reordered), editor);
|
||||
preferences_list_update_buttons (editor);
|
||||
if (help)
|
||||
{
|
||||
GtkWidget *expander = gtk_expander_new (_("Formatting codes and examples"));
|
||||
GtkWidget *label = gtk_label_new (help);
|
||||
gtk_label_set_xalign (GTK_LABEL (label), 0);
|
||||
gtk_label_set_line_wrap (GTK_LABEL (label), TRUE);
|
||||
gtk_label_set_selectable (GTK_LABEL (label), TRUE);
|
||||
gtk_label_set_max_width_chars (GTK_LABEL (label), 60);
|
||||
gtk_widget_set_margin_top (label, 10);
|
||||
gtk_container_add (GTK_CONTAINER (expander), label);
|
||||
gtk_box_pack_start (GTK_BOX (box), expander, FALSE, FALSE, 0);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
gboolean
|
||||
editlist_preferences_stage (GError **error)
|
||||
{
|
||||
GSList *entry;
|
||||
for (entry = preferences_lists; entry; entry = entry->next)
|
||||
{
|
||||
PreferencesList *editor = entry->data;
|
||||
GtkTreeModel *model = gtk_tree_view_get_model (GTK_TREE_VIEW (editor->view));
|
||||
GtkTreeIter iter;
|
||||
GString *text = g_string_new ("");
|
||||
gboolean valid = gtk_tree_model_get_iter_first (model, &iter);
|
||||
char *live;
|
||||
g_clear_pointer (&editor->pending, g_free);
|
||||
while (valid)
|
||||
{
|
||||
char *name, *cmd;
|
||||
gtk_tree_model_get (model, &iter, NAME_COLUMN, &name, CMD_COLUMN, &cmd, -1);
|
||||
if ((name && strpbrk (name, "\r\n")) || (cmd && strpbrk (cmd, "\r\n")) ||
|
||||
((!name || !*name) && cmd && *cmd))
|
||||
{
|
||||
GtkTreePath *path = gtk_tree_model_get_path (model, &iter);
|
||||
int column = !name || !*name || strpbrk (name, "\r\n") ? NAME_COLUMN : CMD_COLUMN;
|
||||
setup_reveal_widget (editor->view);
|
||||
gtk_widget_grab_focus (editor->view);
|
||||
gtk_tree_view_set_cursor (GTK_TREE_VIEW (editor->view), path,
|
||||
gtk_tree_view_get_column (GTK_TREE_VIEW (editor->view), column), TRUE);
|
||||
gtk_tree_path_free (path);
|
||||
g_set_error_literal (error, G_FILE_ERROR, G_FILE_ERROR_INVAL,
|
||||
_("Give this entry a name and keep each field on one line."));
|
||||
g_free (name); g_free (cmd); g_string_free (text, TRUE);
|
||||
return FALSE;
|
||||
}
|
||||
if (name && *name)
|
||||
g_string_append_printf (text, "NAME %s\nCMD %s\n\n", name, cmd ? cmd : "");
|
||||
g_free (name); g_free (cmd);
|
||||
valid = gtk_tree_model_iter_next (model, &iter);
|
||||
}
|
||||
if (strcmp (text->str, editor->original) == 0) { g_string_free (text, TRUE); continue; }
|
||||
live = preferences_list_serialize (*editor->target);
|
||||
if (strcmp (live, editor->original) != 0)
|
||||
{
|
||||
setup_reveal_widget (editor->view);
|
||||
g_set_error_literal (error, G_FILE_ERROR, G_FILE_ERROR_AGAIN,
|
||||
_("These entries changed outside Preferences. Reopen Preferences before editing them."));
|
||||
g_free (live); g_string_free (text, TRUE);
|
||||
return FALSE;
|
||||
}
|
||||
g_free (live);
|
||||
editor->pending = g_string_free (text, FALSE);
|
||||
preferences_persistence_stage_file (editor->file, editor->pending);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void
|
||||
editlist_preferences_commit (void)
|
||||
{
|
||||
GSList *entry, *sessions;
|
||||
for (entry = preferences_lists; entry; entry = entry->next)
|
||||
{
|
||||
PreferencesList *editor = entry->data;
|
||||
if (!editor->pending) continue;
|
||||
list_free (editor->target);
|
||||
list_loadconf ((char *)editor->file, editor->target, NULL);
|
||||
if (editor->target == &usermenu_list) usermenu_update ();
|
||||
for (sessions = sess_list; sessions; sessions = sessions->next)
|
||||
{
|
||||
if (editor->target == &button_list) fe_buttons_update (sessions->data);
|
||||
if (editor->target == &dlgbutton_list) fe_dlgbuttons_update (sessions->data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,10 @@
|
||||
#define ZOITECHAT_EDITLIST_H
|
||||
|
||||
void editlist_gui_open (char *title1, char *title2, GSList * list, char *title, char *wmclass, char *file, char *help);
|
||||
GtkWidget *editlist_preferences_create (GSList **list, const char *file,
|
||||
const char *title1, const char *title2,
|
||||
const char *help);
|
||||
gboolean editlist_preferences_stage (GError **error);
|
||||
void editlist_preferences_commit (void);
|
||||
|
||||
#endif
|
||||
+22
-1
@@ -21,6 +21,10 @@
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "fe-gtk.h"
|
||||
#ifdef __APPLE__
|
||||
#include "../common/macos.h"
|
||||
#include "macos-shortcuts.h"
|
||||
#endif
|
||||
|
||||
#ifdef GDK_WINDOWING_WIN32
|
||||
#include <gdk/gdkwin32.h>
|
||||
@@ -372,7 +376,13 @@ fe_args (int argc, char *argv[])
|
||||
g_free (locale_path);
|
||||
g_free (base_path);
|
||||
#else
|
||||
bindtextdomain (GETTEXT_PACKAGE, LOCALEDIR);
|
||||
bindtextdomain (GETTEXT_PACKAGE,
|
||||
#ifdef __APPLE__
|
||||
zoitechat_macos_locale_dir ()
|
||||
#else
|
||||
LOCALEDIR
|
||||
#endif
|
||||
);
|
||||
#endif
|
||||
bind_textdomain_codeset (GETTEXT_PACKAGE, "UTF-8");
|
||||
textdomain (GETTEXT_PACKAGE);
|
||||
@@ -493,6 +503,17 @@ fe_args (int argc, char *argv[])
|
||||
#endif
|
||||
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);
|
||||
gtk_icon_theme_prepend_search_path (gtk_icon_theme_get_default (), icons);
|
||||
g_free (icons);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef WIN32
|
||||
win32_configure_icon_theme ();
|
||||
#endif
|
||||
|
||||
@@ -63,6 +63,7 @@ powershell "Get-Content -Encoding UTF8 '$(ZoiteChatLib)zoitechat.rc.utf8' | Out-
|
||||
<ClInclude Include="joind.h" />
|
||||
<ClInclude Include="maingui.h" />
|
||||
<ClInclude Include="preferences-persistence.h" />
|
||||
<ClInclude Include="preferences-pages.h" />
|
||||
<ClInclude Include="menu.h" />
|
||||
<ClInclude Include="notifications\notification-backend.h" />
|
||||
<ClInclude Include="notifygui.h" />
|
||||
@@ -113,6 +114,7 @@ powershell "Get-Content -Encoding UTF8 '$(ZoiteChatLib)zoitechat.rc.utf8' | Out-
|
||||
<ClCompile Include="notifygui.c" />
|
||||
<ClCompile Include="pixmaps.c" />
|
||||
<ClCompile Include="preferences-persistence.c" />
|
||||
<ClCompile Include="preferences-pages.c" />
|
||||
<ClCompile Include="plugin-notification.c" />
|
||||
<ClCompile Include="plugin-tray.c" />
|
||||
<ClCompile Include="plugingui.c" />
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
<ClInclude Include="preferences-persistence.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="preferences-pages.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="menu.h">
|
||||
<Filter>Header Files</Filter>
|
||||
</ClInclude>
|
||||
@@ -194,6 +197,9 @@
|
||||
<ClCompile Include="preferences-persistence.c">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="preferences-pages.c">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
<ClCompile Include="plugingui.c">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
|
||||
+210
-676
@@ -55,6 +55,12 @@
|
||||
#include "maingui.h"
|
||||
#include "textgui.h"
|
||||
#include "fkeys.h"
|
||||
#ifdef __APPLE__
|
||||
#include "macos-shortcuts.h"
|
||||
#endif
|
||||
#include "setup.h"
|
||||
#include "preferences-persistence.h"
|
||||
#include "preferences-pages.h"
|
||||
|
||||
#define ICON_FKEYS_NEW "document-new"
|
||||
#define ICON_FKEYS_DELETE "edit-delete"
|
||||
@@ -107,7 +113,7 @@ struct gcomp_data
|
||||
|
||||
static int key_load_kbs (void);
|
||||
static int key_load_kbs_from_buffer (char *ibuf, off_t size, GSList **out_list);
|
||||
static int key_save_kbs (void);
|
||||
|
||||
static void key_dialog_load (GtkListStore *store);
|
||||
static void key_dialog_reset (GtkWidget *wid, gpointer userdata);
|
||||
static int key_action_handle_command (GtkWidget * wid, GdkEventKey * evt,
|
||||
@@ -156,53 +162,6 @@ static int key_action_reopen_closed_tab (GtkWidget * wid, GdkEventKey * evt,
|
||||
|
||||
static GSList *keybind_list = NULL;
|
||||
|
||||
#define KEY_DIALOG_THEME_LISTENER_ID_KEY "fkeys.theme-listener-id"
|
||||
|
||||
static void
|
||||
key_dialog_theme_apply (GtkWidget *window)
|
||||
{
|
||||
GtkWidget *xtext;
|
||||
XTextColor xtext_palette[XTEXT_COLS];
|
||||
|
||||
if (!window)
|
||||
return;
|
||||
|
||||
xtext = g_object_get_data (G_OBJECT (window), "xtext");
|
||||
if (!xtext)
|
||||
return;
|
||||
|
||||
theme_get_xtext_colors (xtext_palette, XTEXT_COLS);
|
||||
gtk_xtext_set_palette (GTK_XTEXT (xtext), xtext_palette);
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_theme_changed (const ThemeChangedEvent *event, gpointer userdata)
|
||||
{
|
||||
GtkWidget *window = userdata;
|
||||
|
||||
if (!theme_changed_event_has_reason (event, THEME_CHANGED_REASON_PALETTE) &&
|
||||
!theme_changed_event_has_reason (event, THEME_CHANGED_REASON_THEME_PACK) &&
|
||||
!theme_changed_event_has_reason (event, THEME_CHANGED_REASON_MODE))
|
||||
return;
|
||||
|
||||
key_dialog_theme_apply (window);
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_theme_destroy_cb (GtkWidget *widget, gpointer userdata)
|
||||
{
|
||||
guint listener_id;
|
||||
|
||||
(void) userdata;
|
||||
|
||||
listener_id = GPOINTER_TO_UINT (g_object_get_data (G_OBJECT (widget), KEY_DIALOG_THEME_LISTENER_ID_KEY));
|
||||
if (listener_id)
|
||||
{
|
||||
theme_listener_unregister (listener_id);
|
||||
g_object_set_data (G_OBJECT (widget), KEY_DIALOG_THEME_LISTENER_ID_KEY, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
static gsize
|
||||
key_action_decode_escapes (const char *input, char *output)
|
||||
{
|
||||
@@ -362,71 +321,8 @@ static const struct key_action key_actions[KEY_MAX_ACTIONS + 1] = {
|
||||
"ACCEL=F1\nMenu Shortcut\nD1:contents\nD2!\n\n"\
|
||||
"ACCEL=<Primary><Shift>T\nReopen Closed Tab\nD1!\nD2!\n\n"
|
||||
|
||||
static gboolean
|
||||
key_builtin_data_match (char *line, char *data)
|
||||
{
|
||||
if (line[2] == '!')
|
||||
return data == NULL || data[0] == 0;
|
||||
|
||||
if (line[2] == ':')
|
||||
return !strcmp (&line[3], data ? data : "");
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
key_binding_is_builtin (struct key_binding *kb)
|
||||
{
|
||||
char *buf, *ibuf;
|
||||
char *action;
|
||||
int pnt = 0;
|
||||
int state = 0;
|
||||
gboolean match = FALSE;
|
||||
gboolean d1_match = FALSE;
|
||||
off_t size;
|
||||
|
||||
if (kb->action < 0 || kb->action > KEY_MAX_ACTIONS)
|
||||
return FALSE;
|
||||
|
||||
action = key_actions[kb->action].name;
|
||||
ibuf = g_strdup (default_kb_cfg);
|
||||
size = strlen (default_kb_cfg);
|
||||
|
||||
while (buf_get_line (ibuf, &buf, &pnt, size))
|
||||
{
|
||||
if (strlen (buf) == 0)
|
||||
continue;
|
||||
|
||||
switch (state)
|
||||
{
|
||||
case 0:
|
||||
state = 1;
|
||||
break;
|
||||
case 1:
|
||||
match = !strcmp (buf, action);
|
||||
state = 2;
|
||||
break;
|
||||
case 2:
|
||||
d1_match = match && key_builtin_data_match (buf, kb->data1);
|
||||
state = 3;
|
||||
break;
|
||||
case 3:
|
||||
if (d1_match && key_builtin_data_match (buf, kb->data2))
|
||||
{
|
||||
g_free (ibuf);
|
||||
return TRUE;
|
||||
}
|
||||
state = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
g_free (ibuf);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void
|
||||
key_init ()
|
||||
key_init (void)
|
||||
{
|
||||
if (key_load_kbs () == 1)
|
||||
{
|
||||
@@ -570,6 +466,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:
|
||||
@@ -610,13 +512,13 @@ key_get_menu_accel (const char *name, guint *keyval, GdkModifierType *mod)
|
||||
|
||||
enum
|
||||
{
|
||||
KEY_COLUMN,
|
||||
ACCEL_COLUMN,
|
||||
ACTION_COLUMN,
|
||||
D1_COLUMN,
|
||||
D2_COLUMN,
|
||||
CUSTOM_COLUMN,
|
||||
N_COLUMNS
|
||||
KEY_COLUMN = PREFERENCES_SHORTCUT_LABEL,
|
||||
ACCEL_COLUMN = PREFERENCES_SHORTCUT_ACCEL,
|
||||
ACTION_COLUMN = PREFERENCES_SHORTCUT_ACTION,
|
||||
D1_COLUMN = PREFERENCES_SHORTCUT_DATA1,
|
||||
D2_COLUMN = PREFERENCES_SHORTCUT_DATA2,
|
||||
CUSTOM_COLUMN = PREFERENCES_SHORTCUT_CUSTOM,
|
||||
N_COLUMNS = PREFERENCES_SHORTCUT_N_COLUMNS
|
||||
};
|
||||
|
||||
static GtkWidget *key_dialog = NULL;
|
||||
@@ -624,280 +526,15 @@ static GtkWidget *key_dialog = NULL;
|
||||
static inline GtkTreeModel *
|
||||
get_store (void)
|
||||
{
|
||||
return gtk_tree_view_get_model (g_object_get_data (G_OBJECT (key_dialog), "view"));
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_print_text (GtkXText *xtext, char *text)
|
||||
{
|
||||
unsigned int old = prefs.hex_stamp_text;
|
||||
prefs.hex_stamp_text = 0; /* temporarily disable stamps */
|
||||
gtk_xtext_clear (GTK_XTEXT (xtext)->buffer, 0);
|
||||
PrintTextRaw (GTK_XTEXT (xtext)->buffer, text, 0, 0);
|
||||
prefs.hex_stamp_text = old;
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_set_key (GtkCellRendererAccel *accel, gchar *pathstr, guint accel_key,
|
||||
GdkModifierType accel_mods, guint hardware_keycode, gpointer userdata)
|
||||
{
|
||||
GtkTreeModel *model = get_store ();
|
||||
GtkTreePath *path = gtk_tree_path_new_from_string (pathstr);
|
||||
GtkTreeIter iter;
|
||||
gchar *label_name, *accel_name;
|
||||
|
||||
/* Shift tab requires an exception, hopefully that list ends here.. */
|
||||
if (accel_key == GDK_KEY_Tab && accel_mods & GDK_SHIFT_MASK)
|
||||
accel_key = GDK_KEY_ISO_Left_Tab;
|
||||
|
||||
label_name = gtk_accelerator_get_label (accel_key, key_modifier_get_valid (accel_mods));
|
||||
accel_name = gtk_accelerator_name (accel_key, key_modifier_get_valid (accel_mods));
|
||||
|
||||
gtk_tree_model_get_iter (model, &iter, path);
|
||||
gtk_list_store_set (GTK_LIST_STORE (model), &iter, KEY_COLUMN, label_name,
|
||||
ACCEL_COLUMN, accel_name, -1);
|
||||
|
||||
gtk_tree_path_free (path);
|
||||
g_free (label_name);
|
||||
g_free (accel_name);
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_combo_changed (GtkCellRendererCombo *combo, gchar *pathstr,
|
||||
GtkTreeIter *new_iter, gpointer data)
|
||||
{
|
||||
GtkTreeModel *model;
|
||||
GtkXText *xtext;
|
||||
gchar *actiontext = NULL;
|
||||
gint action;
|
||||
|
||||
xtext = GTK_XTEXT (g_object_get_data (G_OBJECT (key_dialog), "xtext"));
|
||||
model = GTK_TREE_MODEL (data);
|
||||
|
||||
gtk_tree_model_get (model, new_iter, 0, &actiontext, -1);
|
||||
|
||||
if (actiontext)
|
||||
{
|
||||
#ifdef WIN32
|
||||
/* We need to manually update the store */
|
||||
GtkTreePath *path;
|
||||
GtkTreeIter iter;
|
||||
|
||||
path = gtk_tree_path_new_from_string (pathstr);
|
||||
model = get_store ();
|
||||
|
||||
gtk_tree_model_get_iter (model, &iter, path);
|
||||
gtk_list_store_set (GTK_LIST_STORE (model), &iter, ACTION_COLUMN, actiontext, -1);
|
||||
|
||||
gtk_tree_path_free (path);
|
||||
#endif
|
||||
|
||||
action = key_get_action_from_string (actiontext);
|
||||
key_dialog_print_text (xtext, key_actions[action].help);
|
||||
|
||||
g_free (actiontext);
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_entry_edited (GtkCellRendererText *render, gchar *pathstr, gchar *new_text, gpointer data)
|
||||
{
|
||||
GtkTreeModel *model = get_store ();
|
||||
GtkTreePath *path = gtk_tree_path_new_from_string (pathstr);
|
||||
GtkTreeIter iter;
|
||||
gint column = GPOINTER_TO_INT (data);
|
||||
|
||||
gtk_tree_model_get_iter (model, &iter, path);
|
||||
gtk_list_store_set (GTK_LIST_STORE (model), &iter, column, new_text, -1);
|
||||
|
||||
gtk_tree_path_free (path);
|
||||
}
|
||||
|
||||
static gboolean
|
||||
key_dialog_keypress (GtkWidget *wid, GdkEventKey *evt, gpointer userdata)
|
||||
{
|
||||
GtkTreeView *view = g_object_get_data (G_OBJECT (key_dialog), "view");
|
||||
GtkTreeModel *store;
|
||||
GtkTreeIter iter1, iter2;
|
||||
GtkTreeSelection *sel;
|
||||
GtkTreePath *path;
|
||||
gboolean handled = FALSE;
|
||||
int delta;
|
||||
|
||||
if (evt->state & GDK_SHIFT_MASK)
|
||||
{
|
||||
if (evt->keyval == GDK_KEY_Up)
|
||||
{
|
||||
handled = TRUE;
|
||||
delta = -1;
|
||||
}
|
||||
else if (evt->keyval == GDK_KEY_Down)
|
||||
{
|
||||
handled = TRUE;
|
||||
delta = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (handled)
|
||||
{
|
||||
gboolean custom1, custom2;
|
||||
|
||||
sel = gtk_tree_view_get_selection (view);
|
||||
if (!gtk_tree_selection_get_selected (sel, &store, &iter1))
|
||||
return FALSE;
|
||||
|
||||
path = gtk_tree_model_get_path (store, &iter1);
|
||||
if (delta == 1)
|
||||
gtk_tree_path_next (path);
|
||||
else if (!gtk_tree_path_prev (path))
|
||||
{
|
||||
gtk_tree_path_free (path);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (!gtk_tree_model_get_iter (store, &iter2, path))
|
||||
{
|
||||
gtk_tree_path_free (path);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
gtk_tree_path_free (path);
|
||||
gtk_tree_model_get (store, &iter1, CUSTOM_COLUMN, &custom1, -1);
|
||||
gtk_tree_model_get (store, &iter2, CUSTOM_COLUMN, &custom2, -1);
|
||||
if (custom1 && custom2)
|
||||
gtk_list_store_swap (GTK_LIST_STORE (store), &iter1, &iter2);
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_selection_changed (GtkTreeSelection *sel, gpointer userdata)
|
||||
{
|
||||
GtkTreeModel *model;
|
||||
GtkTreeIter iter;
|
||||
GtkXText *xtext;
|
||||
GtkWidget *delete_button;
|
||||
char *actiontext;
|
||||
gboolean custom;
|
||||
int action;
|
||||
|
||||
delete_button = g_object_get_data (G_OBJECT (key_dialog), "delete_button");
|
||||
if (!gtk_tree_selection_get_selected (sel, &model, &iter) || model == NULL)
|
||||
{
|
||||
if (delete_button)
|
||||
gtk_widget_set_sensitive (delete_button, FALSE);
|
||||
return;
|
||||
}
|
||||
|
||||
xtext = GTK_XTEXT (g_object_get_data (G_OBJECT (key_dialog), "xtext"));
|
||||
gtk_tree_model_get (model, &iter, ACTION_COLUMN, &actiontext, CUSTOM_COLUMN, &custom, -1);
|
||||
if (delete_button)
|
||||
gtk_widget_set_sensitive (delete_button, custom);
|
||||
|
||||
if (actiontext)
|
||||
{
|
||||
action = key_get_action_from_string (actiontext);
|
||||
key_dialog_print_text (xtext, key_actions[action].help);
|
||||
g_free (actiontext);
|
||||
}
|
||||
else
|
||||
key_dialog_print_text (xtext, _("Select a row to get help information on its Action."));
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_close (GtkWidget *wid, gpointer userdata)
|
||||
{
|
||||
gtk_widget_destroy (key_dialog);
|
||||
key_dialog = NULL;
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_save (GtkWidget *wid, gpointer userdata)
|
||||
{
|
||||
GtkTreeModel *store = get_store ();
|
||||
GtkTreeIter iter;
|
||||
struct key_binding *kb;
|
||||
char *data1, *data2, *accel, *actiontext;
|
||||
guint keyval;
|
||||
GdkModifierType mod;
|
||||
|
||||
if (keybind_list)
|
||||
{
|
||||
g_slist_free_full (keybind_list, key_free);
|
||||
keybind_list = NULL;
|
||||
}
|
||||
|
||||
if (gtk_tree_model_get_iter_first (GTK_TREE_MODEL (store), &iter))
|
||||
{
|
||||
do
|
||||
{
|
||||
kb = g_new0 (struct key_binding, 1);
|
||||
|
||||
gtk_tree_model_get (GTK_TREE_MODEL (store), &iter, ACCEL_COLUMN, &accel,
|
||||
ACTION_COLUMN, &actiontext,
|
||||
D1_COLUMN, &data1,
|
||||
D2_COLUMN, &data2,
|
||||
-1);
|
||||
kb->data1 = data1;
|
||||
kb->data2 = data2;
|
||||
|
||||
if (accel)
|
||||
{
|
||||
gtk_accelerator_parse (accel, &keyval, &mod);
|
||||
|
||||
kb->keyval = keyval;
|
||||
kb->mod = key_modifier_get_valid (mod);
|
||||
|
||||
g_free (accel);
|
||||
}
|
||||
|
||||
if (actiontext)
|
||||
{
|
||||
kb->action = key_get_action_from_string (actiontext);
|
||||
g_free (actiontext);
|
||||
}
|
||||
|
||||
if (!accel || !actiontext)
|
||||
key_free (kb);
|
||||
else
|
||||
keybind_list = g_slist_append (keybind_list, kb);
|
||||
|
||||
}
|
||||
while (gtk_tree_model_iter_next (GTK_TREE_MODEL (store), &iter));
|
||||
}
|
||||
|
||||
if (key_save_kbs () == 0)
|
||||
{
|
||||
menu_update_quit_accel ();
|
||||
key_dialog_close (wid, NULL);
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_add (GtkWidget *wid, gpointer userdata)
|
||||
{
|
||||
GtkTreeView *view = g_object_get_data (G_OBJECT (key_dialog), "view");
|
||||
GtkTreeViewColumn *col;
|
||||
GtkListStore *store = GTK_LIST_STORE (get_store ());
|
||||
GtkTreeIter iter;
|
||||
GtkTreePath *path;
|
||||
|
||||
gtk_list_store_append (store, &iter);
|
||||
gtk_list_store_set (store, &iter, CUSTOM_COLUMN, TRUE, -1);
|
||||
|
||||
/* make sure the new row is visible and selected */
|
||||
path = gtk_tree_model_get_path (GTK_TREE_MODEL (store), &iter);
|
||||
col = gtk_tree_view_get_column (view, ACTION_COLUMN);
|
||||
gtk_tree_view_scroll_to_cell (view, path, NULL, FALSE, 0.0, 0.0);
|
||||
gtk_tree_view_set_cursor (view, path, col, TRUE);
|
||||
gtk_tree_path_free (path);
|
||||
return g_object_get_data (G_OBJECT (key_dialog), "bindings-store");
|
||||
}
|
||||
|
||||
static char *
|
||||
key_binding_signature (const char *action, const char *data1, const char *data2)
|
||||
{
|
||||
return g_strdup_printf ("%s\\n%s\\n%s", action ? action : "", data1 ? data1 : "", data2 ? data2 : "");
|
||||
const char *a = action ? action : "", *b = data1 ? data1 : "", *c = data2 ? data2 : "";
|
||||
return g_strdup_printf ("%" G_GSIZE_FORMAT ":%s%" G_GSIZE_FORMAT ":%s%" G_GSIZE_FORMAT ":%s",
|
||||
strlen (a), a, strlen (b), b, strlen (c), c);
|
||||
}
|
||||
|
||||
static int
|
||||
@@ -1022,309 +659,152 @@ key_dialog_reset (GtkWidget *wid, gpointer userdata)
|
||||
g_slist_free_full (list, key_free);
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_delete (GtkWidget *wid, gpointer userdata)
|
||||
{
|
||||
GtkTreeView *view = g_object_get_data (G_OBJECT (key_dialog), "view");
|
||||
GtkListStore *store = GTK_LIST_STORE (gtk_tree_view_get_model (view));
|
||||
GtkTreeIter iter;
|
||||
GtkTreePath *path;
|
||||
gboolean custom;
|
||||
|
||||
if (gtkutil_treeview_get_selected (view, &iter, -1))
|
||||
{
|
||||
gtk_tree_model_get (GTK_TREE_MODEL (store), &iter, CUSTOM_COLUMN, &custom, -1);
|
||||
if (!custom)
|
||||
return;
|
||||
|
||||
/* delete this row, select next one */
|
||||
if (gtk_list_store_remove (store, &iter))
|
||||
{
|
||||
path = gtk_tree_model_get_path (GTK_TREE_MODEL (store), &iter);
|
||||
gtk_tree_view_scroll_to_cell (view, path, NULL, TRUE, 1.0, 0.0);
|
||||
gtk_tree_view_set_cursor (view, path, NULL, FALSE);
|
||||
gtk_tree_path_free (path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static GtkWidget *
|
||||
key_dialog_treeview_new (GtkWidget *box)
|
||||
{
|
||||
GtkWidget *scroll;
|
||||
GtkListStore *store, *combostore;
|
||||
GtkTreeViewColumn *col;
|
||||
GtkWidget *view;
|
||||
GtkCellRenderer *render;
|
||||
int i;
|
||||
|
||||
scroll = gtk_scrolled_window_new (NULL, NULL);
|
||||
gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC);
|
||||
gtk_scrolled_window_set_shadow_type (GTK_SCROLLED_WINDOW (scroll), GTK_SHADOW_IN);
|
||||
|
||||
store = gtk_list_store_new (N_COLUMNS, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING,
|
||||
G_TYPE_STRING, G_TYPE_STRING, G_TYPE_BOOLEAN);
|
||||
g_return_val_if_fail (store != NULL, NULL);
|
||||
|
||||
view = gtk_tree_view_new_with_model (GTK_TREE_MODEL (store));
|
||||
gtk_tree_view_set_fixed_height_mode (GTK_TREE_VIEW (view), TRUE);
|
||||
gtk_tree_view_set_enable_search (GTK_TREE_VIEW (view), FALSE);
|
||||
gtk_tree_view_set_reorderable (GTK_TREE_VIEW (view), FALSE);
|
||||
|
||||
g_signal_connect (G_OBJECT (view), "key-press-event",
|
||||
G_CALLBACK (key_dialog_keypress), NULL);
|
||||
g_signal_connect (G_OBJECT (gtk_tree_view_get_selection (GTK_TREE_VIEW(view))),
|
||||
"changed", G_CALLBACK (key_dialog_selection_changed), NULL);
|
||||
|
||||
gtk_widget_set_name (view, "fkeys-treeview");
|
||||
if (!theme_gtk3_is_active ())
|
||||
{
|
||||
GtkCssProvider *provider = gtk_css_provider_new ();
|
||||
|
||||
gtk_css_provider_load_from_data (
|
||||
provider,
|
||||
"treeview#fkeys-treeview row:nth-child(odd) {"
|
||||
" background-color: @theme_base_color;"
|
||||
"}"
|
||||
"treeview#fkeys-treeview row:nth-child(even) {"
|
||||
" background-color: shade(@theme_base_color, 0.96);"
|
||||
"}",
|
||||
-1,
|
||||
NULL);
|
||||
theme_css_apply_widget_provider (view, GTK_STYLE_PROVIDER (provider));
|
||||
g_object_unref (provider);
|
||||
}
|
||||
|
||||
render = gtk_cell_renderer_accel_new ();
|
||||
g_object_set (render, "editable", TRUE,
|
||||
#ifndef WIN32
|
||||
"accel-mode", GTK_CELL_RENDERER_ACCEL_MODE_OTHER,
|
||||
#endif
|
||||
NULL);
|
||||
g_signal_connect (G_OBJECT (render), "accel-edited",
|
||||
G_CALLBACK (key_dialog_set_key), NULL);
|
||||
gtk_tree_view_insert_column_with_attributes (GTK_TREE_VIEW (view), KEY_COLUMN,
|
||||
"Key", render,
|
||||
"text", KEY_COLUMN,
|
||||
NULL);
|
||||
|
||||
render = gtk_cell_renderer_text_new ();
|
||||
gtk_tree_view_insert_column_with_attributes (
|
||||
GTK_TREE_VIEW (view), ACCEL_COLUMN,
|
||||
"Accel", render,
|
||||
"text", ACCEL_COLUMN,
|
||||
NULL);
|
||||
|
||||
combostore = gtk_list_store_new (1, G_TYPE_STRING);
|
||||
for (i = 0; i <= KEY_MAX_ACTIONS; i++)
|
||||
{
|
||||
GtkTreeIter iter;
|
||||
|
||||
if (key_actions[i].name[0])
|
||||
{
|
||||
gtk_list_store_append (combostore, &iter);
|
||||
gtk_list_store_set (combostore, &iter, 0, key_actions[i].name, -1);
|
||||
}
|
||||
}
|
||||
|
||||
render = gtk_cell_renderer_combo_new ();
|
||||
g_object_set (G_OBJECT (render), "model", combostore,
|
||||
"has-entry", FALSE,
|
||||
"editable", TRUE,
|
||||
"text-column", 0,
|
||||
NULL);
|
||||
g_signal_connect (G_OBJECT (render), "edited",
|
||||
G_CALLBACK (key_dialog_entry_edited), GINT_TO_POINTER (ACTION_COLUMN));
|
||||
g_signal_connect (G_OBJECT (render), "changed",
|
||||
G_CALLBACK (key_dialog_combo_changed), combostore);
|
||||
gtk_tree_view_insert_column_with_attributes (GTK_TREE_VIEW (view), ACTION_COLUMN,
|
||||
"Action", render,
|
||||
"text", ACTION_COLUMN,
|
||||
"editable", CUSTOM_COLUMN,
|
||||
NULL);
|
||||
|
||||
render = gtk_cell_renderer_text_new ();
|
||||
g_object_set (render, "editable", TRUE, NULL);
|
||||
g_signal_connect (G_OBJECT (render), "edited",
|
||||
G_CALLBACK (key_dialog_entry_edited), GINT_TO_POINTER (D1_COLUMN));
|
||||
gtk_tree_view_insert_column_with_attributes (
|
||||
GTK_TREE_VIEW (view), D1_COLUMN,
|
||||
"Data1", render,
|
||||
"text", D1_COLUMN,
|
||||
"editable", CUSTOM_COLUMN,
|
||||
NULL);
|
||||
|
||||
render = gtk_cell_renderer_text_new ();
|
||||
g_object_set (render, "editable", TRUE, NULL);
|
||||
g_signal_connect (G_OBJECT (render), "edited",
|
||||
G_CALLBACK (key_dialog_entry_edited), GINT_TO_POINTER (D2_COLUMN));
|
||||
gtk_tree_view_insert_column_with_attributes (
|
||||
GTK_TREE_VIEW (view), D2_COLUMN,
|
||||
"Data2", render,
|
||||
"text", D2_COLUMN,
|
||||
"editable", CUSTOM_COLUMN,
|
||||
NULL);
|
||||
|
||||
col = gtk_tree_view_get_column (GTK_TREE_VIEW (view), KEY_COLUMN);
|
||||
gtk_tree_view_column_set_fixed_width (col, 200);
|
||||
gtk_tree_view_column_set_resizable (col, TRUE);
|
||||
col = gtk_tree_view_get_column (GTK_TREE_VIEW (view), ACCEL_COLUMN);
|
||||
gtk_tree_view_column_set_visible (col, FALSE);
|
||||
col = gtk_tree_view_get_column (GTK_TREE_VIEW (view), ACTION_COLUMN);
|
||||
gtk_tree_view_column_set_fixed_width (col, 160);
|
||||
col = gtk_tree_view_get_column (GTK_TREE_VIEW (view), D1_COLUMN);
|
||||
gtk_tree_view_column_set_sizing (col, GTK_TREE_VIEW_COLUMN_AUTOSIZE);
|
||||
gtk_tree_view_column_set_min_width (col, 80);
|
||||
gtk_tree_view_column_set_resizable (col, TRUE);
|
||||
col = gtk_tree_view_get_column (GTK_TREE_VIEW (view), D2_COLUMN);
|
||||
gtk_tree_view_column_set_sizing (col, GTK_TREE_VIEW_COLUMN_AUTOSIZE);
|
||||
gtk_tree_view_column_set_min_width (col, 80);
|
||||
gtk_tree_view_column_set_resizable (col, TRUE);
|
||||
|
||||
gtk_container_add (GTK_CONTAINER (scroll), view);
|
||||
gtk_box_pack_start (GTK_BOX (box), scroll, TRUE, TRUE, 0);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
static void
|
||||
key_dialog_load (GtkListStore *store)
|
||||
{
|
||||
struct key_binding *kb = NULL;
|
||||
char *label_text, *accel_text;
|
||||
GtkTreeIter iter;
|
||||
GSList *list = keybind_list;
|
||||
|
||||
while (list)
|
||||
GHashTable *defaults = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
|
||||
GHashTable *seen = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
|
||||
GSList *reference = NULL;
|
||||
key_load_kbs_from_buffer (g_strdup (default_kb_cfg), strlen (default_kb_cfg), &reference);
|
||||
for (GSList *item = reference; item; item = item->next)
|
||||
{
|
||||
kb = (struct key_binding*)list->data;
|
||||
|
||||
label_text = gtk_accelerator_get_label (kb->keyval, kb->mod);
|
||||
accel_text = gtk_accelerator_name (kb->keyval, kb->mod);
|
||||
|
||||
gtk_list_store_append (store, &iter);
|
||||
gtk_list_store_set (store, &iter,
|
||||
KEY_COLUMN, label_text,
|
||||
ACCEL_COLUMN, accel_text,
|
||||
ACTION_COLUMN, key_actions[kb->action].name,
|
||||
D1_COLUMN, kb->data1,
|
||||
D2_COLUMN, kb->data2,
|
||||
CUSTOM_COLUMN, !key_binding_is_builtin (kb), -1);
|
||||
|
||||
g_free (accel_text);
|
||||
g_free (label_text);
|
||||
|
||||
list = g_slist_next (list);
|
||||
struct key_binding *kb = item->data;
|
||||
key_dialog_reset_increment (defaults, key_binding_signature (key_actions[kb->action].name, kb->data1, kb->data2));
|
||||
}
|
||||
/* Protect only the built-in number of occurrences of an action and its
|
||||
* parameters. Extra copies remain custom after save/reload, including
|
||||
* copies whose only difference is their key. No runtime order changes. */
|
||||
for (GSList *item = keybind_list; item; item = item->next)
|
||||
{
|
||||
struct key_binding *kb = item->data;
|
||||
GtkTreeIter iter;
|
||||
char *signature = key_binding_signature (key_actions[kb->action].name, kb->data1, kb->data2);
|
||||
gboolean custom = key_dialog_reset_count (seen, signature) >= key_dialog_reset_count (defaults, signature);
|
||||
key_dialog_reset_increment (seen, signature);
|
||||
char *label = gtk_accelerator_get_label (kb->keyval, kb->mod);
|
||||
char *accel = gtk_accelerator_name (kb->keyval, kb->mod);
|
||||
gtk_list_store_append (store, &iter);
|
||||
gtk_list_store_set (store, &iter, KEY_COLUMN, label, ACCEL_COLUMN, accel,
|
||||
ACTION_COLUMN, key_actions[kb->action].name, D1_COLUMN, kb->data1,
|
||||
D2_COLUMN, kb->data2, CUSTOM_COLUMN, custom, -1);
|
||||
g_free (label); g_free (accel);
|
||||
}
|
||||
g_slist_free_full (reference, key_free);
|
||||
g_hash_table_destroy (defaults); g_hash_table_destroy (seen);
|
||||
}
|
||||
|
||||
static char *key_preferences_serialize (GtkTreeModel *model, GError **error);
|
||||
|
||||
static void
|
||||
key_preferences_restore (gpointer unused)
|
||||
{
|
||||
key_dialog_reset (NULL, NULL);
|
||||
}
|
||||
|
||||
GtkWidget *
|
||||
key_preferences_create (void)
|
||||
{
|
||||
GtkListStore *store = gtk_list_store_new (N_COLUMNS, G_TYPE_STRING, G_TYPE_STRING,
|
||||
G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_BOOLEAN);
|
||||
PreferencesShortcutAction actions[KEY_MAX_ACTIONS + 1];
|
||||
for (guint i = 0; i < G_N_ELEMENTS (actions); i++)
|
||||
{
|
||||
actions[i].id = key_actions[i].name;
|
||||
actions[i].help = key_actions[i].help;
|
||||
}
|
||||
key_dialog_load (store);
|
||||
key_dialog = preferences_shortcuts_page_new (store, actions, G_N_ELEMENTS (actions),
|
||||
key_modifier_get_valid, key_preferences_restore, NULL);
|
||||
g_object_add_weak_pointer (G_OBJECT (key_dialog), (gpointer *)&key_dialog);
|
||||
g_object_set_data_full (G_OBJECT (key_dialog), "original-bindings",
|
||||
key_preferences_serialize (GTK_TREE_MODEL (store), NULL), g_free);
|
||||
g_object_unref (store);
|
||||
return key_dialog;
|
||||
}
|
||||
|
||||
void
|
||||
key_dialog_show ()
|
||||
key_dialog_show (void)
|
||||
{
|
||||
GtkWidget *vbox, *box, *pane, *scroll;
|
||||
GtkWidget *view, *xtext, *delete_button;
|
||||
GtkListStore *store;
|
||||
XTextColor xtext_palette[XTEXT_COLS];
|
||||
char buf[128];
|
||||
|
||||
if (key_dialog)
|
||||
{
|
||||
mg_bring_tofront (key_dialog);
|
||||
return;
|
||||
}
|
||||
|
||||
g_snprintf(buf, sizeof(buf), _("Keyboard Shortcuts - %s"), _(DISPLAY_NAME));
|
||||
key_dialog = mg_create_generic_tab ("editkeys", buf, TRUE, FALSE, key_dialog_close,
|
||||
NULL, 600, 360, &vbox, 0);
|
||||
|
||||
pane = gtk_paned_new (GTK_ORIENTATION_VERTICAL);
|
||||
gtk_box_pack_start (GTK_BOX (vbox), pane, TRUE, TRUE, 0);
|
||||
box = gtkutil_box_new (GTK_ORIENTATION_VERTICAL, FALSE, 0);
|
||||
gtk_paned_pack1 (GTK_PANED (pane), box, TRUE, FALSE);
|
||||
view = key_dialog_treeview_new (box);
|
||||
scroll = gtk_scrolled_window_new (NULL, NULL);
|
||||
gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scroll),
|
||||
GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
|
||||
gtk_scrolled_window_set_min_content_height (GTK_SCROLLED_WINDOW (scroll), 80);
|
||||
gtk_paned_pack2 (GTK_PANED (pane), scroll, TRUE, FALSE);
|
||||
gtk_paned_set_position (GTK_PANED (pane), 220);
|
||||
theme_get_xtext_colors (xtext_palette, XTEXT_COLS);
|
||||
xtext = gtk_xtext_new (xtext_palette, 0);
|
||||
gtk_container_add (GTK_CONTAINER (scroll), xtext);
|
||||
gtk_xtext_set_font (GTK_XTEXT (xtext), prefs.hex_text_font);
|
||||
|
||||
g_object_set_data (G_OBJECT (key_dialog), "view", view);
|
||||
g_object_set_data (G_OBJECT (key_dialog), "xtext", xtext);
|
||||
g_object_set_data (G_OBJECT (key_dialog), KEY_DIALOG_THEME_LISTENER_ID_KEY,
|
||||
GUINT_TO_POINTER (theme_listener_register ("fkeys.window", key_dialog_theme_changed, key_dialog)));
|
||||
g_signal_connect (G_OBJECT (key_dialog), "destroy", G_CALLBACK (key_dialog_theme_destroy_cb), NULL);
|
||||
|
||||
box = gtk_button_box_new (GTK_ORIENTATION_HORIZONTAL);
|
||||
gtk_button_box_set_layout (GTK_BUTTON_BOX (box), GTK_BUTTONBOX_SPREAD);
|
||||
gtk_box_pack_start (GTK_BOX (vbox), box, FALSE, FALSE, 2);
|
||||
gtk_container_set_border_width (GTK_CONTAINER (box), 5);
|
||||
|
||||
gtkutil_button (box, ICON_FKEYS_NEW, NULL, key_dialog_add,
|
||||
NULL, _("Add"));
|
||||
delete_button = gtkutil_button (box, ICON_FKEYS_DELETE, NULL, key_dialog_delete,
|
||||
NULL, _("Delete"));
|
||||
g_object_set_data (G_OBJECT (key_dialog), "delete_button", delete_button);
|
||||
gtk_widget_set_sensitive (delete_button, FALSE);
|
||||
gtkutil_button (box, ICON_FKEYS_RESET, NULL, key_dialog_reset,
|
||||
NULL, _("Reset"));
|
||||
gtkutil_button (box, ICON_FKEYS_CANCEL, NULL, key_dialog_close,
|
||||
NULL, _("Cancel"));
|
||||
gtkutil_button (box, ICON_FKEYS_SAVE, NULL, key_dialog_save,
|
||||
NULL, _("Save"));
|
||||
|
||||
store = GTK_LIST_STORE (gtk_tree_view_get_model (GTK_TREE_VIEW (view)));
|
||||
key_dialog_load (store);
|
||||
|
||||
gtk_widget_show_all (key_dialog);
|
||||
setup_open_page ("keyboard");
|
||||
}
|
||||
|
||||
static int
|
||||
key_save_kbs (void)
|
||||
static char *
|
||||
key_preferences_serialize (GtkTreeModel *model, GError **error)
|
||||
{
|
||||
int fd;
|
||||
char buf[512];
|
||||
char *accel_text;
|
||||
GSList *list = keybind_list;
|
||||
struct key_binding *kb;
|
||||
|
||||
fd = zoitechat_open_file ("keybindings.conf", O_CREAT | O_TRUNC | O_WRONLY,
|
||||
0x180, XOF_DOMODE);
|
||||
if (fd < 0)
|
||||
return 1;
|
||||
write (fd, buf, g_snprintf (buf, 510, "# ZoiteChat key bindings config file\n\n"));
|
||||
|
||||
while (list)
|
||||
GtkTreeIter iter;
|
||||
GString *text = g_string_new ("# ZoiteChat key bindings config file\n\n");
|
||||
gboolean valid = gtk_tree_model_get_iter_first (model, &iter);
|
||||
while (valid)
|
||||
{
|
||||
kb = list->data;
|
||||
|
||||
accel_text = gtk_accelerator_name (kb->keyval, kb->mod);
|
||||
|
||||
g_snprintf (buf, 510, "ACCEL=%s\n%s\n", accel_text, key_actions[kb->action].name);
|
||||
write (fd, buf, strlen (buf));
|
||||
g_free (accel_text);
|
||||
|
||||
if (kb->data1 && kb->data1[0])
|
||||
write (fd, buf, g_snprintf (buf, 510, "D1:%s\n", kb->data1));
|
||||
else
|
||||
write (fd, "D1!\n", 4);
|
||||
|
||||
if (kb->data2 && kb->data2[0])
|
||||
write (fd, buf, g_snprintf (buf, 510, "D2:%s\n", kb->data2));
|
||||
else
|
||||
write (fd, "D2!\n", 4);
|
||||
|
||||
write (fd, "\n", 1);
|
||||
|
||||
list = g_slist_next (list);
|
||||
char *accel, *action, *data1, *data2;
|
||||
guint key;
|
||||
GdkModifierType modifiers;
|
||||
gtk_tree_model_get (model, &iter, ACCEL_COLUMN, &accel, ACTION_COLUMN, &action,
|
||||
D1_COLUMN, &data1, D2_COLUMN, &data2, -1);
|
||||
if ((!accel || !*accel) && (!action || !*action) && (!data1 || !*data1) && (!data2 || !*data2))
|
||||
goto next;
|
||||
gtk_accelerator_parse (accel ? accel : "", &key, &modifiers);
|
||||
if (!key || !action || strcmp (key_actions[key_get_action_from_string (action)].name, action) != 0 ||
|
||||
(data1 && strpbrk (data1, "\r\n")) || (data2 && strpbrk (data2, "\r\n")))
|
||||
{
|
||||
GtkTreeView *view = g_object_get_data (G_OBJECT (key_dialog), "view");
|
||||
GtkTreePath *path = gtk_tree_model_get_path (model, &iter);
|
||||
setup_reveal_widget (GTK_WIDGET (view));
|
||||
preferences_shortcuts_reveal_path (key_dialog, path);
|
||||
gtk_widget_grab_focus (GTK_WIDGET (view));
|
||||
gtk_tree_path_free (path);
|
||||
g_set_error_literal (error, G_FILE_ERROR, G_FILE_ERROR_INVAL,
|
||||
_("Complete each keyboard shortcut with a key and an action. Data fields must stay on one line."));
|
||||
g_free (accel); g_free (action); g_free (data1); g_free (data2);
|
||||
g_string_free (text, TRUE);
|
||||
return NULL;
|
||||
}
|
||||
g_string_append_printf (text, "ACCEL=%s\n%s\n", accel, action);
|
||||
if (data1 && *data1) g_string_append_printf (text, "D1:%s\n", data1);
|
||||
else g_string_append (text, "D1!\n");
|
||||
if (data2 && *data2) g_string_append_printf (text, "D2:%s\n", data2);
|
||||
else g_string_append (text, "D2!\n");
|
||||
g_string_append_c (text, '\n');
|
||||
next:
|
||||
g_free (accel); g_free (action); g_free (data1); g_free (data2);
|
||||
valid = gtk_tree_model_iter_next (model, &iter);
|
||||
}
|
||||
return g_string_free (text, FALSE);
|
||||
}
|
||||
|
||||
close (fd);
|
||||
return 0;
|
||||
gboolean
|
||||
key_preferences_stage (GError **error)
|
||||
{
|
||||
char *contents;
|
||||
const char *original;
|
||||
if (!key_dialog) return TRUE;
|
||||
contents = key_preferences_serialize (get_store (), error);
|
||||
if (!contents) return FALSE;
|
||||
original = g_object_get_data (G_OBJECT (key_dialog), "original-bindings");
|
||||
g_object_set_data_full (G_OBJECT (key_dialog), "pending-bindings", NULL, g_free);
|
||||
if (g_strcmp0 (contents, original) != 0)
|
||||
{
|
||||
preferences_persistence_stage_file ("keybindings.conf", contents);
|
||||
g_object_set_data_full (G_OBJECT (key_dialog), "pending-bindings", g_strdup (contents), g_free);
|
||||
}
|
||||
g_free (contents);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void
|
||||
key_preferences_commit (void)
|
||||
{
|
||||
GSList *bindings = NULL;
|
||||
const char *contents;
|
||||
if (!key_dialog) return;
|
||||
contents = g_object_get_data (G_OBJECT (key_dialog), "pending-bindings");
|
||||
if (!contents) return;
|
||||
if (key_load_kbs_from_buffer (g_strdup (contents), strlen (contents), &bindings) == 0)
|
||||
{
|
||||
g_slist_free_full (keybind_list, key_free);
|
||||
keybind_list = bindings;
|
||||
menu_update_quit_accel ();
|
||||
}
|
||||
}
|
||||
|
||||
#define KBSTATE_MOD 0
|
||||
@@ -1333,6 +813,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 +1050,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;
|
||||
|
||||
|
||||
@@ -31,10 +31,16 @@
|
||||
|
||||
void key_init (void);
|
||||
void key_dialog_show (void);
|
||||
GtkWidget *key_preferences_create (void);
|
||||
gboolean key_preferences_stage (GError **error);
|
||||
void key_preferences_commit (void);
|
||||
int key_handle_key_press (GtkWidget * wid, GdkEventKey * evt, session *sess);
|
||||
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
|
||||
+22
-18
@@ -31,6 +31,7 @@
|
||||
#include "../common/fe.h"
|
||||
#include "gtkutil.h"
|
||||
#include "maingui.h"
|
||||
#include "setup.h"
|
||||
|
||||
#define ICON_IGNORE_NEW "document-new"
|
||||
#define ICON_IGNORE_DELETE "edit-delete"
|
||||
@@ -234,6 +235,8 @@ ignore_delete_entry_clicked (GtkWidget * wid, struct session *sess)
|
||||
static void
|
||||
ignore_store_new (int cancel, char *mask, gpointer data)
|
||||
{
|
||||
if (cancel || !ignorewin)
|
||||
return;
|
||||
GtkTreeView *view = g_object_get_data (G_OBJECT (ignorewin), "view");
|
||||
GtkListStore *store = GTK_LIST_STORE (get_store ());
|
||||
GtkTreeIter iter;
|
||||
@@ -265,6 +268,11 @@ ignore_store_new (int cancel, char *mask, gpointer data)
|
||||
static void
|
||||
ignore_clear_cb (GtkDialog *dialog, gint response)
|
||||
{
|
||||
if (!ignorewin)
|
||||
{
|
||||
gtk_widget_destroy (GTK_WIDGET (dialog));
|
||||
return;
|
||||
}
|
||||
GtkListStore *store = GTK_LIST_STORE (get_store ());
|
||||
GtkTreeIter iter;
|
||||
char *mask;
|
||||
@@ -293,7 +301,7 @@ ignore_clear_entry_clicked (GtkWidget * wid)
|
||||
{
|
||||
GtkWidget *dialog;
|
||||
|
||||
dialog = gtk_message_dialog_new (NULL, 0,
|
||||
dialog = gtk_message_dialog_new (GTK_WINDOW (gtk_widget_get_toplevel (ignorewin)), GTK_DIALOG_DESTROY_WITH_PARENT,
|
||||
GTK_MESSAGE_QUESTION, GTK_BUTTONS_OK_CANCEL,
|
||||
_("Are you sure you want to remove all ignores?"));
|
||||
theme_manager_attach_window (dialog);
|
||||
@@ -335,8 +343,8 @@ ignore_stats_entry (GtkWidget * box, char *label, int value)
|
||||
return wid;
|
||||
}
|
||||
|
||||
void
|
||||
ignore_gui_open ()
|
||||
GtkWidget *
|
||||
ignore_preferences_create (void)
|
||||
{
|
||||
GtkWidget *vbox, *box, *stat_box, *frame;
|
||||
GtkWidget *view;
|
||||
@@ -345,20 +353,9 @@ ignore_gui_open ()
|
||||
GSList *temp = ignore_list;
|
||||
char *mask;
|
||||
gboolean private, chan, notice, ctcp, dcc, invite, unignore;
|
||||
char buf[128];
|
||||
|
||||
if (ignorewin)
|
||||
{
|
||||
mg_bring_tofront (ignorewin);
|
||||
return;
|
||||
}
|
||||
|
||||
g_snprintf(buf, sizeof(buf), _("Ignore list - %s"), _(DISPLAY_NAME));
|
||||
ignorewin =
|
||||
mg_create_generic_tab ("IgnoreList", buf, FALSE, TRUE,
|
||||
close_ignore_gui_callback,
|
||||
NULL, 700, 300, &vbox, 0);
|
||||
gtkutil_destroy_on_esc (ignorewin);
|
||||
vbox = gtk_box_new (GTK_ORIENTATION_VERTICAL, 10);
|
||||
ignorewin = vbox;
|
||||
g_signal_connect (vbox, "destroy", G_CALLBACK (close_ignore_gui_callback), NULL);
|
||||
|
||||
view = ignore_treeview_new (vbox);
|
||||
g_object_set_data (G_OBJECT (ignorewin), "view", view);
|
||||
@@ -421,7 +418,14 @@ ignore_gui_open ()
|
||||
|
||||
temp = temp->next;
|
||||
}
|
||||
gtk_widget_show (ignorewin);
|
||||
gtk_widget_show_all (ignorewin);
|
||||
return vbox;
|
||||
}
|
||||
|
||||
void
|
||||
ignore_gui_open (void)
|
||||
{
|
||||
setup_open_page ("ignore");
|
||||
}
|
||||
|
||||
void
|
||||
|
||||
@@ -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
|
||||
@@ -297,40 +297,6 @@ mg_color_component_to_pango (double value)
|
||||
return (guint16)(value * 65535.0 + 0.5);
|
||||
}
|
||||
|
||||
static void
|
||||
mg_apply_font_css (GtkWidget *widget, const PangoFontDescription *desc,
|
||||
const char *class_name, const char *provider_key)
|
||||
{
|
||||
GtkStyleContext *context;
|
||||
GtkCssProvider *provider;
|
||||
GString *css;
|
||||
|
||||
if (!widget || !desc)
|
||||
return;
|
||||
|
||||
context = gtk_widget_get_style_context (widget);
|
||||
if (!context)
|
||||
return;
|
||||
|
||||
provider = g_object_get_data (G_OBJECT (widget), provider_key);
|
||||
if (!provider)
|
||||
{
|
||||
provider = gtk_css_provider_new ();
|
||||
g_object_set_data_full (G_OBJECT (widget), provider_key, provider, g_object_unref);
|
||||
}
|
||||
|
||||
css = g_string_new (".");
|
||||
g_string_append (css, class_name);
|
||||
g_string_append (css, " {");
|
||||
gtkutil_append_font_css (css, desc);
|
||||
g_string_append (css, " }");
|
||||
gtk_css_provider_load_from_data (provider, css->str, -1, NULL);
|
||||
g_string_free (css, TRUE);
|
||||
|
||||
gtk_style_context_add_class (context, class_name);
|
||||
theme_css_apply_widget_provider (widget, GTK_STYLE_PROVIDER (provider));
|
||||
}
|
||||
|
||||
static void
|
||||
mg_set_label_alignment_start (GtkWidget *widget)
|
||||
{
|
||||
|
||||
+124
-164
@@ -50,7 +50,11 @@
|
||||
#include "banlist.h"
|
||||
#include "chanlist.h"
|
||||
#include "editlist.h"
|
||||
#include "setup.h"
|
||||
#include "fkeys.h"
|
||||
#ifdef __APPLE__
|
||||
#include "macos-shortcuts.h"
|
||||
#endif
|
||||
#include "gtkutil.h"
|
||||
#include "maingui.h"
|
||||
#include "notifygui.h"
|
||||
@@ -1318,24 +1322,24 @@ menu_open_server_list (GtkWidget *wid, gpointer none)
|
||||
static void
|
||||
menu_settings (GtkWidget * wid, gpointer none)
|
||||
{
|
||||
extern void setup_open (void);
|
||||
setup_open ();
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
static void
|
||||
menu_usermenu (void)
|
||||
menu_macos_minimize (GtkWidget *widget, gpointer userdata)
|
||||
{
|
||||
char buf[128];
|
||||
g_snprintf(buf, sizeof(buf), _("User menu - %s"), _(DISPLAY_NAME));
|
||||
editlist_gui_open (NULL, NULL, usermenu_list, buf, "usermenu", "usermenu.conf", 0);
|
||||
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
|
||||
usermenu_create (GtkWidget *menu)
|
||||
{
|
||||
menu_create (menu, usermenu_list, "", FALSE);
|
||||
menu_quick_item (0, 0, menu, XCMENU_SHADED, 0, 0); /* sep */
|
||||
menu_quick_item_with_callback (menu_usermenu, _("Edit This Menu" ELLIPSIS), menu, 0);
|
||||
}
|
||||
|
||||
static void
|
||||
@@ -1644,33 +1648,6 @@ menu_banlist (GtkWidget * wid, gpointer none)
|
||||
banlist_opengui (current_sess);
|
||||
}
|
||||
|
||||
#ifdef USE_PLUGIN
|
||||
|
||||
static void
|
||||
menu_loadplugin (void)
|
||||
{
|
||||
plugingui_load ();
|
||||
}
|
||||
|
||||
static void
|
||||
menu_pluginlist (void)
|
||||
{
|
||||
plugingui_open ();
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
static void
|
||||
menu_noplugin_info (void)
|
||||
{
|
||||
fe_message (_(DISPLAY_NAME " has been build without plugin support."), FE_MSG_INFO);
|
||||
}
|
||||
|
||||
#define menu_loadplugin menu_noplugin_info
|
||||
#define menu_pluginlist menu_noplugin_info
|
||||
|
||||
#endif
|
||||
|
||||
#define usercommands_help _("User Commands - Special codes:\n\n"\
|
||||
"%c = current channel\n"\
|
||||
"%e = current network name\n"\
|
||||
@@ -1727,73 +1704,47 @@ menu_noplugin_info (void)
|
||||
"shell instead of ZoiteChat")
|
||||
|
||||
static void
|
||||
menu_usercommands (void)
|
||||
menu_preferences_selector_changed (GtkComboBox *combo, GtkStack *stack)
|
||||
{
|
||||
char buf[128];
|
||||
g_snprintf(buf, sizeof(buf), _("User Defined Commands - %s"), _(DISPLAY_NAME));
|
||||
editlist_gui_open (NULL, NULL, command_list, buf, "commands", "commands.conf",
|
||||
usercommands_help);
|
||||
const char *id = gtk_combo_box_get_active_id (combo);
|
||||
if (id) gtk_stack_set_visible_child_name (stack, id);
|
||||
}
|
||||
|
||||
static void
|
||||
menu_ulpopup (void)
|
||||
GtkWidget *
|
||||
menu_preferences_create (const char *id)
|
||||
{
|
||||
char buf[128];
|
||||
g_snprintf(buf, sizeof(buf), _("Userlist Popup menu - %s"), _(DISPLAY_NAME));
|
||||
editlist_gui_open (NULL, NULL, popup_list, buf, "popup", "popup.conf", ulbutton_help);
|
||||
}
|
||||
|
||||
static void
|
||||
menu_rpopup (void)
|
||||
{
|
||||
char buf[128];
|
||||
g_snprintf(buf, sizeof(buf), _("Replace - %s"), _(DISPLAY_NAME));
|
||||
editlist_gui_open (_("Text"), _("Replace with"), replace_list, buf, "replace", "replace.conf", 0);
|
||||
}
|
||||
|
||||
static void
|
||||
menu_urlhandlers (void)
|
||||
{
|
||||
char buf[128];
|
||||
g_snprintf(buf, sizeof(buf), _("URL Handlers - %s"), _(DISPLAY_NAME));
|
||||
editlist_gui_open (NULL, NULL, urlhandler_list, buf, "urlhandlers", "urlhandlers.conf", url_help);
|
||||
}
|
||||
|
||||
static void
|
||||
menu_evtpopup (void)
|
||||
{
|
||||
pevent_dialog_show ();
|
||||
}
|
||||
|
||||
static void
|
||||
menu_keypopup (void)
|
||||
{
|
||||
key_dialog_show ();
|
||||
}
|
||||
|
||||
static void
|
||||
menu_ulbuttons (void)
|
||||
{
|
||||
char buf[128];
|
||||
g_snprintf(buf, sizeof(buf), _("Userlist buttons - %s"), _(DISPLAY_NAME));
|
||||
editlist_gui_open (NULL, NULL, button_list, buf, "buttons", "buttons.conf", ulbutton_help);
|
||||
}
|
||||
|
||||
static void
|
||||
menu_dlgbuttons (void)
|
||||
{
|
||||
char buf[128];
|
||||
g_snprintf(buf, sizeof(buf), _("Dialog buttons - %s"), _(DISPLAY_NAME));
|
||||
editlist_gui_open (NULL, NULL, dlgbutton_list, buf, "dlgbuttons", "dlgbuttons.conf",
|
||||
dlgbutton_help);
|
||||
}
|
||||
|
||||
static void
|
||||
menu_ctcpguiopen (void)
|
||||
{
|
||||
char buf[128];
|
||||
g_snprintf(buf, sizeof(buf), _("CTCP Replies - %s"), _(DISPLAY_NAME));
|
||||
editlist_gui_open (NULL, NULL, ctcp_list, buf, "ctcpreply", "ctcpreply.conf", ctcp_help);
|
||||
if (!strcmp (id, "replace"))
|
||||
return editlist_preferences_create (&replace_list, "replace.conf", _("Text"), _("Replace with"), NULL);
|
||||
if (!strcmp (id, "commands"))
|
||||
return editlist_preferences_create (&command_list, "commands.conf", _("Command"), _("Expansion"), usercommands_help);
|
||||
if (!strcmp (id, "urls"))
|
||||
return editlist_preferences_create (&urlhandler_list, "urlhandlers.conf", _("Name"), _("Command"), url_help);
|
||||
if (!strcmp (id, "ctcp"))
|
||||
return editlist_preferences_create (&ctcp_list, "ctcpreply.conf", _("Request"), _("Reply"), ctcp_help);
|
||||
{
|
||||
GtkWidget *box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 10);
|
||||
GtkWidget *selector = gtk_combo_box_text_new ();
|
||||
GtkWidget *stack = gtk_stack_new ();
|
||||
const char *ids[] = {"buttons", "popup", "dlgbuttons", "usermenu"};
|
||||
const char *names[] = {N_("User list buttons"), N_("User list popup"), N_("Dialog buttons"), N_("User menu")};
|
||||
const char *files[] = {"buttons.conf", "popup.conf", "dlgbuttons.conf", "usermenu.conf"};
|
||||
GSList **lists[] = {&button_list, &popup_list, &dlgbutton_list, &usermenu_list};
|
||||
const char *help[] = {ulbutton_help, ulbutton_help, dlgbutton_help, ulbutton_help};
|
||||
int i;
|
||||
gtk_box_pack_start (GTK_BOX (box), selector, FALSE, FALSE, 0);
|
||||
gtk_box_pack_start (GTK_BOX (box), stack, TRUE, TRUE, 0);
|
||||
gtk_stack_set_homogeneous (GTK_STACK (stack), FALSE);
|
||||
for (i = 0; i < 4; i++)
|
||||
{
|
||||
gtk_combo_box_text_append (GTK_COMBO_BOX_TEXT (selector), ids[i], _(names[i]));
|
||||
gtk_stack_add_named (GTK_STACK (stack), editlist_preferences_create (lists[i], files[i], _("Name"), _("Command"), help[i]), ids[i]);
|
||||
}
|
||||
g_signal_connect (selector, "changed", G_CALLBACK (menu_preferences_selector_changed), stack);
|
||||
gtk_combo_box_set_active (GTK_COMBO_BOX (selector), 0);
|
||||
g_object_set_data (G_OBJECT (box), "menus-selector", selector);
|
||||
g_object_set_data (G_OBJECT (stack), "preferences-selector", selector);
|
||||
return box;
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
@@ -1962,7 +1913,10 @@ menu_about (GtkWidget *wid, gpointer sess)
|
||||
gtk_about_dialog_set_logo (dialog, pix_zoitechat);
|
||||
gtk_about_dialog_set_copyright (dialog, "\302\251 1998-2010 Peter \305\275elezn\303\275\n\302\251 2009-2014 Berke Viktor\n\302\251 2015-2025 Patrick Griffis\n\302\251 2026 deepend");
|
||||
gtk_about_dialog_set_comments (dialog, comment);
|
||||
/* Preserve the About dialog's existing action-area button layout. */
|
||||
G_GNUC_BEGIN_IGNORE_DEPRECATIONS
|
||||
actions = gtk_dialog_get_action_area (GTK_DIALOG (dialog));
|
||||
G_GNUC_END_IGNORE_DEPRECATIONS
|
||||
children = gtk_container_get_children (GTK_CONTAINER (actions));
|
||||
for (child = children; child; child = child->next)
|
||||
gtk_widget_destroy (GTK_WIDGET (child->data));
|
||||
@@ -1983,7 +1937,7 @@ menu_about (GtkWidget *wid, gpointer sess)
|
||||
|
||||
static struct mymenu mymenu[] = {
|
||||
{N_("_ZoiteChat"), 0, 0, M_NEWMENU, MENU_ID_ZOITECHAT, 0, 1},
|
||||
{N_("Network Li_st"), menu_open_server_list, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("Network Li_st" ELLIPSIS), menu_open_server_list, 0, M_MENUITEM, 0, 0, 1},
|
||||
{0, 0, 0, M_SEP, 0, 0, 0},
|
||||
|
||||
{N_("_New"), 0, 0, M_MENUSUB, 0, 0, 1},
|
||||
@@ -1994,14 +1948,14 @@ static struct mymenu mymenu[] = {
|
||||
{0, 0, 0, M_END, 0, 0, 0},
|
||||
{0, 0, 0, M_SEP, 0, 0, 0},
|
||||
|
||||
{N_("_Load Plugin or Script" ELLIPSIS), menu_loadplugin, 0, M_MENUITEM, 0, 0, 1},
|
||||
{0, 0, 0, M_SEP, 0, 0, 0}, /* 11 */
|
||||
#define DETACH_OFFSET (12)
|
||||
{0, menu_detach, 0, M_MENUITEM, 0, 0, 1}, /* 12 */
|
||||
#define CLOSE_OFFSET (13)
|
||||
#define DETACH_OFFSET (10)
|
||||
{0, menu_detach, 0, M_MENUITEM, 0, 0, 1},
|
||||
#define CLOSE_OFFSET (11)
|
||||
{0, menu_close, 0, M_MENUITEM, 0, 0, 1},
|
||||
{0, 0, 0, M_SEP, 0, 0, 0},
|
||||
{N_("_Quit"), menu_quit, 0, M_MENUITEM, MENU_ID_QUIT, 0, 1}, /* 15 */
|
||||
{N_("_Preferences" ELLIPSIS), menu_settings, 0, M_MENUITEM, 0, 0, 1},
|
||||
{0, 0, 0, M_SEP, 0, 0, 0},
|
||||
{N_("_Quit"), menu_quit, 0, M_MENUITEM, MENU_ID_QUIT, 0, 1},
|
||||
|
||||
{N_("_View"), 0, 0, M_NEWMENU, 0, 0, 1},
|
||||
#define MENUBAR_OFFSET (17)
|
||||
@@ -2011,18 +1965,18 @@ static struct mymenu mymenu[] = {
|
||||
{N_("U_ser List Buttons"), menu_ulbuttons_toggle, 0, M_MENUTOG, MENU_ID_ULBUTTONS, 0, 1},
|
||||
{N_("M_ode Buttons"), menu_cmbuttons_toggle, 0, M_MENUTOG, MENU_ID_MODEBUTTONS, 0, 1},
|
||||
{0, 0, 0, M_SEP, 0, 0, 0},
|
||||
{N_("_Channel Switcher"), 0, 0, M_MENUSUB, 0, 0, 1}, /* 23 */
|
||||
{N_("_Channel Switcher"), 0, 0, M_MENUSUB, 0, 0, 1},
|
||||
#define TABS_OFFSET (24)
|
||||
{N_("_Tabs"), menu_layout_cb, 0, M_MENURADIO, MENU_ID_LAYOUT_TABS, 0, 1},
|
||||
{N_("T_ree"), 0, 0, M_MENURADIO, MENU_ID_LAYOUT_TREE, 0, 1},
|
||||
{0, 0, 0, M_END, 0, 0, 0},
|
||||
{N_("_Network Meters"), 0, 0, M_MENUSUB, 0, 0, 1}, /* 27 */
|
||||
{N_("_Network Meters"), 0, 0, M_MENUSUB, 0, 0, 1},
|
||||
#define METRE_OFFSET (28)
|
||||
{N_("Off"), menu_metres_off, 0, M_MENURADIO, 0, 0, 1},
|
||||
{N_("Graph"), menu_metres_graph, 0, M_MENURADIO, 0, 0, 1},
|
||||
{N_("Text"), menu_metres_text, 0, M_MENURADIO, 0, 0, 1},
|
||||
{N_("Both"), menu_metres_both, 0, M_MENURADIO, 0, 0, 1},
|
||||
{0, 0, 0, M_END, 0, 0, 0}, /* 32 */
|
||||
{0, 0, 0, M_END, 0, 0, 0},
|
||||
{ 0, 0, 0, M_SEP, 0, 0, 0 },
|
||||
{N_ ("_Fullscreen"), menu_fullscreen_toggle, 0, M_MENUTOG, MENU_ID_FULLSCREEN, 0, 1},
|
||||
|
||||
@@ -2035,20 +1989,7 @@ static struct mymenu mymenu[] = {
|
||||
#define AWAY_OFFSET (41)
|
||||
{N_("Marked _Away"), menu_away_toggle, 0, M_MENUITEM, MENU_ID_AWAY, 0, 1},
|
||||
|
||||
{N_("_Usermenu"), 0, 0, M_NEWMENU, MENU_ID_USERMENU, 0, 1}, /* 40 */
|
||||
|
||||
{N_("S_ettings"), 0, 0, M_NEWMENU, 0, 0, 1},
|
||||
{N_("_Preferences"), menu_settings, 0, M_MENUITEM, 0, 0, 1},
|
||||
{0, 0, 0, M_SEP, 0, 0, 0},
|
||||
{N_("Auto Replace"), menu_rpopup, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("CTCP Replies"), menu_ctcpguiopen, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("Dialog Buttons"), menu_dlgbuttons, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("Keyboard Shortcuts"), menu_keypopup, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("Text Events"), menu_evtpopup, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("URL Handlers"), menu_urlhandlers, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("User Commands"), menu_usercommands, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("User List Buttons"), menu_ulbuttons, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("User List Popup"), menu_ulpopup, 0, M_MENUITEM, 0, 0, 1}, /* 52 */
|
||||
{N_("_Usermenu"), 0, 0, M_NEWMENU, MENU_ID_USERMENU, 0, 1},
|
||||
|
||||
{N_("_Window"), 0, 0, M_NEWMENU, 0, 0, 1},
|
||||
{N_("_Ban List"), menu_banlist, 0, M_MENUITEM, 0, 0, 1},
|
||||
@@ -2056,9 +1997,7 @@ static struct mymenu mymenu[] = {
|
||||
{N_("Direct Chat"), menu_dcc_chat_win, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("File _Transfers"), menu_dcc_win, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("Friends List"), notify_opengui, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("Ignore List"), ignore_gui_open, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("_Plugins and Scripts"), menu_pluginlist, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("_Raw Log"), menu_rawlog, 0, M_MENUITEM, 0, 0, 1}, /* 61 */
|
||||
{N_("_Raw Log"), menu_rawlog, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("_URL Grabber"), url_opengui, 0, M_MENUITEM, 0, 0, 1},
|
||||
{0, 0, 0, M_SEP, 0, 0, 0},
|
||||
{N_("Reset Marker Line"), menu_resetmarker, 0, M_MENUITEM, 0, 0, 1},
|
||||
@@ -2066,14 +2005,14 @@ 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 (57)
|
||||
{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},
|
||||
{N_("Search Previous" ), menu_search_prev, 0, M_MENUITEM, 0, 0, 1},
|
||||
{0, 0, 0, M_END, 0, 0, 0},
|
||||
|
||||
{N_("_Help"), 0, 0, M_NEWMENU, 0, 0, 1}, /* 74 */
|
||||
{N_("_Help"), 0, 0, M_NEWMENU, 0, 0, 1},
|
||||
{N_("_Contents"), menu_docs, 0, M_MENUITEM, 0, 0, 1},
|
||||
{N_("_About"), menu_about, 0, M_MENUITEM, 0, 0, 1},
|
||||
|
||||
@@ -2083,42 +2022,24 @@ static struct mymenu mymenu[] = {
|
||||
static const char *
|
||||
menu_get_key_action_name (int index)
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case 1:
|
||||
return "network-list";
|
||||
case 4:
|
||||
return "new-server-tab";
|
||||
case 6:
|
||||
return "new-server-window";
|
||||
case CLOSE_OFFSET:
|
||||
return "close";
|
||||
case 15:
|
||||
return "quit";
|
||||
case MENUBAR_OFFSET:
|
||||
return "menu-toggle";
|
||||
case MENUBAR_OFFSET + 2:
|
||||
return "user-list-toggle";
|
||||
case 34:
|
||||
return "fullscreen-toggle";
|
||||
case AWAY_OFFSET:
|
||||
return "away-toggle";
|
||||
case 65:
|
||||
return "reset-marker";
|
||||
case 66:
|
||||
return "move-marker";
|
||||
case 67:
|
||||
return "copy-selection";
|
||||
case SEARCH_OFFSET + 1:
|
||||
return "search-text";
|
||||
case SEARCH_OFFSET + 2:
|
||||
return "search-next";
|
||||
case SEARCH_OFFSET + 3:
|
||||
return "search-previous";
|
||||
case 75:
|
||||
return "contents";
|
||||
}
|
||||
|
||||
/* Bind accelerators to actions rather than positions in the menu. */
|
||||
void *callback = mymenu[index].callback;
|
||||
if (callback == menu_open_server_list) return "network-list";
|
||||
if (callback == menu_newserver_tab) return "new-server-tab";
|
||||
if (callback == menu_newserver_window) return "new-server-window";
|
||||
if (callback == menu_close) return "close";
|
||||
if (callback == menu_quit) return "quit";
|
||||
if (callback == menu_bar_toggle_cb) return "menu-toggle";
|
||||
if (callback == menu_userlist_toggle) return "user-list-toggle";
|
||||
if (callback == menu_fullscreen_toggle) return "fullscreen-toggle";
|
||||
if (callback == menu_away_toggle) return "away-toggle";
|
||||
if (callback == menu_resetmarker) return "reset-marker";
|
||||
if (callback == menu_movetomarker) return "move-marker";
|
||||
if (callback == menu_copy_selection) return "copy-selection";
|
||||
if (callback == menu_search) return "search-text";
|
||||
if (callback == menu_search_next) return "search-next";
|
||||
if (callback == menu_search_prev) return "search-previous";
|
||||
if (callback == menu_docs) return "contents";
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -2139,6 +2060,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)
|
||||
{
|
||||
@@ -2161,6 +2096,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));
|
||||
@@ -2214,7 +2155,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);
|
||||
}
|
||||
@@ -2838,6 +2784,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:
|
||||
@@ -2851,6 +2807,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))
|
||||
@@ -2952,7 +2912,7 @@ togitem:
|
||||
|
||||
#ifdef HAVE_GTK_MAC
|
||||
/* We want ZoiteChat to be the app menu, not including Quit or ZoiteChat itself */
|
||||
if (bar && item && i <= CLOSE_OFFSET + 1 && mymenu[i].id != MENU_ID_ZOITECHAT)
|
||||
if (bar && item && (i <= CLOSE_OFFSET + 1 || mymenu[i].callback == menu_settings) && mymenu[i].id != MENU_ID_ZOITECHAT)
|
||||
{
|
||||
if (!submenu || mymenu[i].type == M_MENUSUB)
|
||||
gtkosx_application_insert_app_menu_item (osx_app, item, appmenu_offset++);
|
||||
|
||||
@@ -42,6 +42,7 @@ void menu_bar_toggle (void);
|
||||
void menu_add_plugin_items (GtkWidget *menu, char *root, char *target);
|
||||
void menu_change_layout (void);
|
||||
void menu_update_quit_accel (void);
|
||||
GtkWidget *menu_preferences_create (const char *id);
|
||||
gboolean menu_key_action (const char *name, guint keyval, GdkModifierType state);
|
||||
|
||||
void menu_set_away (session_gui *gui, int away);
|
||||
|
||||
+72
-2
@@ -32,6 +32,7 @@ zoitechat_gtk_sources = [
|
||||
'notifygui.c',
|
||||
'pixmaps.c',
|
||||
'preferences-persistence.c',
|
||||
'preferences-pages.c',
|
||||
'plugin-tray.c',
|
||||
'plugin-notification.c',
|
||||
'rawlog.c',
|
||||
@@ -53,7 +54,17 @@ zoitechat_gtk_deps = [
|
||||
|
||||
gtk_dep = dependency('gtk+-3.0', version: '>= 3.22')
|
||||
|
||||
zoitechat_theme_deps = [gtk_dep, zoitechat_common_dep]
|
||||
# Standalone GTK tests also include the common application's headers.
|
||||
gtk_dep = declare_dependency(dependencies: [gtk_dep] + zoitechat_common_header_deps)
|
||||
|
||||
# fe-gtk.h exposes GtkosxApplication to every GTK consumer when enabled.
|
||||
# Theme libraries and standalone UI tests need the same headers as the app.
|
||||
if host_machine.system() == 'darwin' and gtk_mac_dep.found()
|
||||
gtk_dep = declare_dependency(dependencies: [gtk_dep, gtk_mac_dep])
|
||||
endif
|
||||
|
||||
theme_math_dep = cc.find_library('m', required: false)
|
||||
zoitechat_theme_deps = [gtk_dep, zoitechat_common_dep, theme_math_dep]
|
||||
|
||||
if host_machine.system() != 'windows'
|
||||
appindicator_opt = get_option('appindicator')
|
||||
@@ -91,12 +102,19 @@ if host_machine.system() != 'windows'
|
||||
endif
|
||||
|
||||
zoitechat_gtk_ldflags = []
|
||||
zoitechat_gtk_objcflags = []
|
||||
|
||||
if host_machine.system() == 'windows'
|
||||
zoitechat_gtk_sources += 'notifications/notification-windows.c'
|
||||
zoitechat_gtk_deps += cc.find_library('dwmapi', required: true)
|
||||
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_objcflags += '-fobjc-arc'
|
||||
else
|
||||
zoitechat_gtk_sources += 'notifications/notification-freedesktop.c'
|
||||
endif
|
||||
@@ -137,6 +155,7 @@ executable('zoitechat',
|
||||
dependencies: zoitechat_gtk_deps,
|
||||
link_with: zoitechat_theme_lib,
|
||||
c_args: zoitechat_gtk_cflags,
|
||||
objc_args: zoitechat_gtk_objcflags,
|
||||
link_args: zoitechat_gtk_ldflags,
|
||||
pie: true,
|
||||
install: true,
|
||||
@@ -226,6 +245,50 @@ theme_runtime_tests = executable('theme_runtime_persistence_tests',
|
||||
link_with: zoitechat_theme_lib,
|
||||
)
|
||||
|
||||
preferences_persistence_tests = executable('preferences_persistence_tests',
|
||||
'tests/test-preferences-persistence.c',
|
||||
include_directories: [config_h_include],
|
||||
dependencies: [gtk_dep],
|
||||
)
|
||||
|
||||
test('Preferences Persistence Tests', preferences_persistence_tests,
|
||||
protocol: 'tap',
|
||||
timeout: 120,
|
||||
)
|
||||
|
||||
# g_test_skip() was added in GLib 2.38, below GTK 3.22's minimum GLib
|
||||
# requirement. Allow it in this test target without changing the app's API
|
||||
# ceiling or lowering macOS's existing 2.60 ceiling.
|
||||
preferences_pages_test_cflags = []
|
||||
if host_machine.system() != 'darwin'
|
||||
preferences_pages_test_cflags = [
|
||||
'-UGLIB_VERSION_MAX_ALLOWED',
|
||||
'-DGLIB_VERSION_MAX_ALLOWED=GLIB_VERSION_2_38',
|
||||
]
|
||||
endif
|
||||
|
||||
preferences_pages_tests = executable('preferences_pages_tests',
|
||||
[textevents, 'tests/test-preferences-pages.c', 'preferences-pages.c'],
|
||||
include_directories: [config_h_include, include_directories('../common')],
|
||||
dependencies: [gtk_dep],
|
||||
c_args: preferences_pages_test_cflags,
|
||||
)
|
||||
test('Preferences Pages Tests', preferences_pages_tests,
|
||||
protocol: 'tap',
|
||||
timeout: 120,
|
||||
)
|
||||
|
||||
spell_entry_tests = executable('spell_entry_tests',
|
||||
[marshal, 'tests/test-spell-entry.c'],
|
||||
include_directories: [config_h_include, include_directories('../common')],
|
||||
dependencies: [gtk_dep, libgmodule_dep],
|
||||
c_args: preferences_pages_test_cflags,
|
||||
)
|
||||
test('Spell Entry Tests', spell_entry_tests,
|
||||
protocol: 'tap',
|
||||
timeout: 120,
|
||||
)
|
||||
|
||||
test('Theme Runtime Persistence Tests', theme_runtime_tests,
|
||||
protocol: 'tap',
|
||||
timeout: 120,
|
||||
@@ -260,7 +323,7 @@ test('Theme GTK3 Settings Tests', theme_gtk3_settings_tests,
|
||||
theme_preferences_gtk3_populate_tests = executable('theme_preferences_gtk3_populate_tests',
|
||||
'theme/tests/test-theme-preferences-gtk3-populate.c',
|
||||
include_directories: [config_h_include],
|
||||
dependencies: [gtk_dep],
|
||||
dependencies: [gtk_dep, theme_math_dep],
|
||||
)
|
||||
|
||||
test('Theme Preferences GTK3 Populate Tests', theme_preferences_gtk3_populate_tests,
|
||||
@@ -278,3 +341,10 @@ test('Emoji Font Tests', emoji_font_tests,
|
||||
protocol: 'tap',
|
||||
timeout: 120,
|
||||
)
|
||||
|
||||
xtext_tests = executable('xtext_tests',
|
||||
['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)
|
||||
@@ -1,54 +1,67 @@
|
||||
/* ZoiteChat
|
||||
* Copyright (C) 2015 Patrick Griffis.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
|
||||
*/
|
||||
|
||||
/* Native macOS notifications; GPL-2.0-or-later, see COPYING. */
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#include <gtkosxapplication.h>
|
||||
#import <UserNotifications/UserNotifications.h>
|
||||
|
||||
void
|
||||
notification_backend_show (const char *title, const char *text)
|
||||
@interface ZoiteChatNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
|
||||
@end
|
||||
@implementation ZoiteChatNotificationDelegate
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
|
||||
willPresentNotification:(UNNotification *)notification
|
||||
withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler
|
||||
{
|
||||
NSString *str_title = [[NSString alloc] initWithUTF8String:title];
|
||||
NSString *str_text = [[NSString alloc] initWithUTF8String:text];
|
||||
completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound);
|
||||
}
|
||||
@end
|
||||
|
||||
NSUserNotification *userNotification = [NSUserNotification new];
|
||||
userNotification.title = str_title;
|
||||
userNotification.informativeText = str_text;
|
||||
static ZoiteChatNotificationDelegate *notification_delegate;
|
||||
|
||||
NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter];
|
||||
[center scheduleNotification:userNotification];
|
||||
|
||||
[str_title release];
|
||||
[str_text release];
|
||||
int
|
||||
notification_backend_supported (void)
|
||||
{
|
||||
return [[NSBundle mainBundle] bundleIdentifier] != nil;
|
||||
}
|
||||
|
||||
int
|
||||
notification_backend_init (const char **error)
|
||||
{
|
||||
if (!notification_backend_supported ())
|
||||
{
|
||||
*error = "macOS notifications require an application bundle";
|
||||
return 0;
|
||||
}
|
||||
notification_delegate = [ZoiteChatNotificationDelegate new];
|
||||
[UNUserNotificationCenter currentNotificationCenter].delegate = notification_delegate;
|
||||
return 1;
|
||||
}
|
||||
|
||||
void
|
||||
notification_backend_show (const char *title, const char *text)
|
||||
{
|
||||
UNMutableNotificationContent *content = [UNMutableNotificationContent new];
|
||||
content.title = [NSString stringWithUTF8String:title] ?: @"ZoiteChat";
|
||||
content.body = [NSString stringWithUTF8String:text] ?: @"";
|
||||
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
|
||||
/* Ask only when the user first needs a notification, without blocking GTK. */
|
||||
[center requestAuthorizationWithOptions:UNAuthorizationOptionAlert | UNAuthorizationOptionSound
|
||||
completionHandler:^(BOOL granted, NSError *error) {
|
||||
if (granted)
|
||||
{
|
||||
UNNotificationRequest *request = [UNNotificationRequest
|
||||
requestWithIdentifier:[[NSUUID UUID] UUIDString] content:content trigger:nil];
|
||||
[center addNotificationRequest:request withCompletionHandler:^(NSError *deliveryError) {
|
||||
if (deliveryError)
|
||||
NSLog(@"ZoiteChat notification failed: %@", deliveryError);
|
||||
}];
|
||||
}
|
||||
else if (error)
|
||||
NSLog(@"ZoiteChat notification authorization failed: %@", error);
|
||||
}];
|
||||
}
|
||||
|
||||
void
|
||||
notification_backend_deinit (void)
|
||||
{
|
||||
}
|
||||
|
||||
int
|
||||
notification_backend_supported (void)
|
||||
{
|
||||
return gtkosx_application_get_bundle_id () != NULL;
|
||||
if (notification_delegate)
|
||||
[UNUserNotificationCenter currentNotificationCenter].delegate = nil;
|
||||
notification_delegate = nil;
|
||||
}
|
||||
+14
-14
@@ -35,6 +35,8 @@ typedef struct session zoitechat_context;
|
||||
#include "../common/cfgfiles.h"
|
||||
#include "gtkutil.h"
|
||||
#include "maingui.h"
|
||||
#include "setup.h"
|
||||
#include "plugingui.h"
|
||||
|
||||
/* model for the plugin treeview */
|
||||
enum
|
||||
@@ -357,24 +359,15 @@ plugingui_reloadbutton_cb (GtkWidget *wid, GtkTreeView *view)
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
plugingui_open (void)
|
||||
GtkWidget *
|
||||
plugingui_preferences_create (void)
|
||||
{
|
||||
GtkWidget *view;
|
||||
GtkWidget *view_scroll;
|
||||
GtkWidget *vbox, *hbox;
|
||||
char buf[128];
|
||||
|
||||
if (plugin_window)
|
||||
{
|
||||
mg_bring_tofront (plugin_window);
|
||||
return;
|
||||
}
|
||||
|
||||
g_snprintf(buf, sizeof(buf), _("Plugins and Scripts - %s"), _(DISPLAY_NAME));
|
||||
plugin_window = mg_create_generic_tab ("Addons", buf, FALSE, TRUE, plugingui_close, NULL,
|
||||
700, 300, &vbox, 0);
|
||||
gtkutil_destroy_on_esc (plugin_window);
|
||||
vbox = gtk_box_new (GTK_ORIENTATION_VERTICAL, 10);
|
||||
plugin_window = vbox;
|
||||
g_signal_connect (vbox, "destroy", G_CALLBACK (plugingui_close), NULL);
|
||||
|
||||
view = plugingui_treeview_new (vbox);
|
||||
view_scroll = gtk_widget_get_parent (view);
|
||||
@@ -404,4 +397,11 @@ plugingui_open (void)
|
||||
fe_pluginlist_update ();
|
||||
|
||||
gtk_widget_show_all (plugin_window);
|
||||
return vbox;
|
||||
}
|
||||
|
||||
void
|
||||
plugingui_open (void)
|
||||
{
|
||||
setup_open_page ("plugins");
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
#define ZOITECHAT_PLUGINGUI_H
|
||||
|
||||
void plugingui_open (void);
|
||||
GtkWidget *plugingui_preferences_create (void);
|
||||
void plugingui_load (void);
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,62 @@
|
||||
/* ZoiteChat - native editors for the everyday preferences pages.
|
||||
* Licensed under the GNU General Public License, version 2 or later.
|
||||
*/
|
||||
#ifndef ZOITECHAT_PREFERENCES_PAGES_H
|
||||
#define ZOITECHAT_PREFERENCES_PAGES_H
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
struct zoitechatprefs;
|
||||
|
||||
typedef enum
|
||||
{
|
||||
PREFERENCES_ALERT_DESKTOP = 1 << 0,
|
||||
PREFERENCES_ALERT_TRAY = 1 << 1,
|
||||
PREFERENCES_ALERT_ATTENTION = 1 << 2,
|
||||
PREFERENCES_ALERT_TRAY_BEHAVIOR = 1 << 3,
|
||||
PREFERENCES_ALERT_HIDDEN_ONLY = 1 << 4
|
||||
} PreferencesAlertCapabilities;
|
||||
|
||||
typedef void (*PreferencesSoundChanged) (gpointer user_data);
|
||||
|
||||
/* All editors write only to the window's staged preferences. The caller owns
|
||||
* both the preferences and the staged Beep event's allocated filename. */
|
||||
GtkWidget *preferences_text_page_new (struct zoitechatprefs *staged,
|
||||
const char *const *languages);
|
||||
GtkWidget *preferences_tabs_page_new (struct zoitechatprefs *staged);
|
||||
GtkWidget *preferences_notifications_page_new (struct zoitechatprefs *staged,
|
||||
PreferencesAlertCapabilities capabilities, char **beep_file,
|
||||
PreferencesSoundChanged sound_changed, gpointer user_data);
|
||||
void preferences_notifications_refresh_sound (GtkWidget *page);
|
||||
GtkWidget *preferences_history_page_new (struct zoitechatprefs *staged,
|
||||
const char *config_dir);
|
||||
gboolean preferences_history_page_stage (GtkWidget *page, GError **error);
|
||||
|
||||
typedef struct
|
||||
{
|
||||
const char *code;
|
||||
const char *name;
|
||||
} PreferencesSpellLanguage;
|
||||
typedef void (*PreferencesTypingPreview) (GtkWidget *entry, const struct zoitechatprefs *staged);
|
||||
GtkWidget *preferences_typing_page_new (struct zoitechatprefs *staged, GtkWidget *preview_entry,
|
||||
const PreferencesSpellLanguage *languages, guint count, PreferencesTypingPreview update_preview);
|
||||
gboolean preferences_typing_page_stage (GtkWidget *page, GError **error);
|
||||
|
||||
/* These columns retain the existing keybindings.conf editor's model and order. */
|
||||
enum
|
||||
{
|
||||
PREFERENCES_SHORTCUT_LABEL, PREFERENCES_SHORTCUT_ACCEL,
|
||||
PREFERENCES_SHORTCUT_ACTION, PREFERENCES_SHORTCUT_DATA1,
|
||||
PREFERENCES_SHORTCUT_DATA2, PREFERENCES_SHORTCUT_CUSTOM,
|
||||
PREFERENCES_SHORTCUT_N_COLUMNS
|
||||
};
|
||||
typedef struct { const char *id; const char *help; } PreferencesShortcutAction;
|
||||
typedef GdkModifierType (*PreferencesShortcutModifiers) (GdkModifierType modifiers);
|
||||
typedef void (*PreferencesShortcutsRestore) (gpointer data);
|
||||
GtkWidget *preferences_shortcuts_page_new (GtkListStore *store,
|
||||
const PreferencesShortcutAction *actions, guint count,
|
||||
PreferencesShortcutModifiers normalize, PreferencesShortcutsRestore restore, gpointer data);
|
||||
void preferences_shortcuts_reveal_path (GtkWidget *page, GtkTreePath *raw_path);
|
||||
void preferences_shortcuts_find_action (GtkWidget *page, const char *action_id);
|
||||
|
||||
#endif
|
||||
@@ -23,64 +23,227 @@
|
||||
#include "../common/cfgfiles.h"
|
||||
#include "theme/theme-runtime.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <fcntl.h>
|
||||
#include <glib/gstdio.h>
|
||||
#ifdef WIN32
|
||||
#include <io.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char *name;
|
||||
char *contents;
|
||||
char *temporary;
|
||||
char *backup;
|
||||
gboolean existed;
|
||||
gboolean installed;
|
||||
} PreferencesFile;
|
||||
|
||||
static GPtrArray *staged_files;
|
||||
|
||||
static void
|
||||
preferences_file_free (gpointer data)
|
||||
{
|
||||
PreferencesFile *file = data;
|
||||
if (file->temporary) g_unlink (file->temporary);
|
||||
if (file->backup) g_unlink (file->backup);
|
||||
g_free (file->name);
|
||||
g_free (file->contents);
|
||||
g_free (file->temporary);
|
||||
g_free (file->backup);
|
||||
g_free (file);
|
||||
}
|
||||
|
||||
void
|
||||
preferences_persistence_clear_staged_files (void)
|
||||
{
|
||||
if (staged_files) g_ptr_array_free (staged_files, TRUE);
|
||||
staged_files = NULL;
|
||||
}
|
||||
|
||||
void
|
||||
preferences_persistence_stage_file (const char *name, const char *contents)
|
||||
{
|
||||
PreferencesFile *file;
|
||||
g_return_if_fail (name && contents && !strchr (name, '/') && !strchr (name, '\\'));
|
||||
if (!staged_files)
|
||||
staged_files = g_ptr_array_new_with_free_func (preferences_file_free);
|
||||
file = g_new0 (PreferencesFile, 1);
|
||||
file->name = g_strdup (name);
|
||||
file->contents = g_strdup (contents);
|
||||
g_ptr_array_add (staged_files, file);
|
||||
}
|
||||
|
||||
/* A temporary in the configuration directory stays on the same filesystem.
|
||||
* Restrictive permissions also protect command text and identity settings. */
|
||||
static gboolean
|
||||
preferences_write_temporary (const char *contents, gsize length, char **path)
|
||||
{
|
||||
int fd;
|
||||
gsize written = 0;
|
||||
char *temporary = g_build_filename (get_xdir (), ".preferences-XXXXXX", NULL);
|
||||
fd = g_mkstemp (temporary);
|
||||
if (fd < 0) { g_free (temporary); return FALSE; }
|
||||
while (written < length)
|
||||
{
|
||||
gssize count = write (fd, contents + written, length - written);
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
if (count <= 0)
|
||||
{
|
||||
close (fd);
|
||||
g_unlink (temporary);
|
||||
g_free (temporary);
|
||||
return FALSE;
|
||||
}
|
||||
written += count;
|
||||
}
|
||||
if (close (fd) != 0)
|
||||
{
|
||||
g_unlink (temporary);
|
||||
g_free (temporary);
|
||||
return FALSE;
|
||||
}
|
||||
*path = temporary;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
preferences_backup (PreferencesFile *file)
|
||||
{
|
||||
char *path = g_build_filename (get_xdir (), file->name, NULL);
|
||||
char *contents = NULL;
|
||||
gsize length;
|
||||
GError *error = NULL;
|
||||
gboolean success;
|
||||
if (!g_file_get_contents (path, &contents, &length, &error))
|
||||
{
|
||||
success = g_error_matches (error, G_FILE_ERROR, G_FILE_ERROR_NOENT);
|
||||
g_clear_error (&error);
|
||||
g_free (path);
|
||||
return success;
|
||||
}
|
||||
file->existed = TRUE;
|
||||
success = preferences_write_temporary (contents, length, &file->backup);
|
||||
g_free (contents);
|
||||
g_free (path);
|
||||
return success;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
preferences_install (PreferencesFile *file, const char *temporary)
|
||||
{
|
||||
char *path = g_build_filename (get_xdir (), file->name, NULL);
|
||||
gboolean success;
|
||||
#ifdef WIN32
|
||||
/* Match the existing config/theme save convention on Windows. The
|
||||
* backup remains available if replacement fails after unlinking. */
|
||||
if (file->existed && g_unlink (path) != 0)
|
||||
{
|
||||
g_free (path);
|
||||
return FALSE;
|
||||
}
|
||||
file->installed = TRUE;
|
||||
#endif
|
||||
success = g_rename (temporary, path) == 0;
|
||||
if (success) file->installed = TRUE;
|
||||
g_free (path);
|
||||
return success;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
preferences_restore (PreferencesFile *file)
|
||||
{
|
||||
char *path;
|
||||
gboolean success;
|
||||
if (!file->installed) return TRUE;
|
||||
path = g_build_filename (get_xdir (), file->name, NULL);
|
||||
if (file->existed)
|
||||
{
|
||||
#ifdef WIN32
|
||||
g_unlink (path);
|
||||
#endif
|
||||
success = g_rename (file->backup, path) == 0;
|
||||
/* Retain the recovery copy if restoration itself fails. */
|
||||
if (!success) g_clear_pointer (&file->backup, g_free);
|
||||
}
|
||||
else success = g_unlink (path) == 0 || errno == ENOENT;
|
||||
g_free (path);
|
||||
return success;
|
||||
}
|
||||
|
||||
PreferencesPersistenceResult
|
||||
preferences_persistence_save_all (void)
|
||||
{
|
||||
PreferencesPersistenceResult result;
|
||||
char *config_temp;
|
||||
char *theme_temp;
|
||||
|
||||
result.success = FALSE;
|
||||
PreferencesPersistenceResult result = {0};
|
||||
GPtrArray *files = g_ptr_array_new_with_free_func (preferences_file_free);
|
||||
PreferencesFile *config = g_new0 (PreferencesFile, 1);
|
||||
PreferencesFile *theme = g_new0 (PreferencesFile, 1);
|
||||
guint i;
|
||||
result.retry_possible = TRUE;
|
||||
result.partial_failure = FALSE;
|
||||
result.config_failed = FALSE;
|
||||
result.theme_failed = FALSE;
|
||||
result.failed_file = NULL;
|
||||
config_temp = NULL;
|
||||
theme_temp = NULL;
|
||||
|
||||
if (!save_config_prepare (&config_temp))
|
||||
config->name = g_strdup ("zoitechat.conf");
|
||||
theme->name = g_strdup ("colors.conf");
|
||||
g_ptr_array_add (files, config);
|
||||
g_ptr_array_add (files, theme);
|
||||
if (!save_config_prepare (&config->temporary))
|
||||
{
|
||||
result.config_failed = TRUE;
|
||||
goto done;
|
||||
}
|
||||
|
||||
if (!theme_runtime_save_prepare (&theme_temp))
|
||||
{
|
||||
result.theme_failed = TRUE;
|
||||
goto done;
|
||||
}
|
||||
|
||||
if (!save_config_finalize (config_temp))
|
||||
{
|
||||
result.config_failed = TRUE;
|
||||
goto done;
|
||||
}
|
||||
|
||||
if (!theme_runtime_save_finalize (theme_temp))
|
||||
{
|
||||
result.theme_failed = TRUE;
|
||||
result.partial_failure = TRUE;
|
||||
goto done;
|
||||
}
|
||||
|
||||
result.success = TRUE;
|
||||
|
||||
done:
|
||||
if (!result.success)
|
||||
{
|
||||
if (result.config_failed && result.theme_failed)
|
||||
result.failed_file = "zoitechat.conf and colors.conf";
|
||||
else if (result.config_failed)
|
||||
result.failed_file = "zoitechat.conf";
|
||||
else if (result.theme_failed)
|
||||
result.failed_file = "colors.conf";
|
||||
goto done;
|
||||
}
|
||||
|
||||
save_config_discard (config_temp);
|
||||
theme_runtime_save_discard (theme_temp);
|
||||
g_free (config_temp);
|
||||
g_free (theme_temp);
|
||||
|
||||
if (!theme_runtime_save_prepare (&theme->temporary))
|
||||
{
|
||||
result.theme_failed = TRUE;
|
||||
result.failed_file = "colors.conf";
|
||||
goto done;
|
||||
}
|
||||
for (i = 0; staged_files && i < staged_files->len; i++)
|
||||
{
|
||||
PreferencesFile *queued = g_ptr_array_index (staged_files, i);
|
||||
PreferencesFile *file = g_new0 (PreferencesFile, 1);
|
||||
file->name = g_strdup (queued->name);
|
||||
g_ptr_array_add (files, file);
|
||||
if (!preferences_write_temporary (queued->contents, strlen (queued->contents), &file->temporary))
|
||||
{
|
||||
result.failed_file = queued->name;
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
/* Prepare every file and recovery copy before replacing anything. */
|
||||
for (i = 0; i < files->len; i++)
|
||||
{
|
||||
PreferencesFile *file = g_ptr_array_index (files, i);
|
||||
if (!preferences_backup (file))
|
||||
{
|
||||
result.failed_file = i < 2 ? (i ? "colors.conf" : "zoitechat.conf")
|
||||
: ((PreferencesFile *)g_ptr_array_index (staged_files, i - 2))->name;
|
||||
result.config_failed = i == 0;
|
||||
result.theme_failed = i == 1;
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
for (i = 0; i < files->len; i++)
|
||||
{
|
||||
PreferencesFile *file = g_ptr_array_index (files, i);
|
||||
if (!preferences_install (file, file->temporary))
|
||||
{
|
||||
result.failed_file = i < 2 ? (i ? "colors.conf" : "zoitechat.conf")
|
||||
: ((PreferencesFile *)g_ptr_array_index (staged_files, i - 2))->name;
|
||||
result.config_failed = i == 0;
|
||||
result.theme_failed = i == 1;
|
||||
goto rollback;
|
||||
}
|
||||
}
|
||||
result.success = TRUE;
|
||||
goto done;
|
||||
rollback:
|
||||
for (i = files->len; i > 0; i--)
|
||||
if (!preferences_restore (g_ptr_array_index (files, i - 1)))
|
||||
result.partial_failure = TRUE;
|
||||
done:
|
||||
g_ptr_array_free (files, TRUE);
|
||||
return result;
|
||||
}
|
||||
@@ -34,5 +34,9 @@ typedef struct
|
||||
} PreferencesPersistenceResult;
|
||||
|
||||
PreferencesPersistenceResult preferences_persistence_save_all (void);
|
||||
/* Editors queue serialized settings; runtime state is committed only after
|
||||
* every queued file and the main preferences have been saved successfully. */
|
||||
void preferences_persistence_stage_file (const char *name, const char *contents);
|
||||
void preferences_persistence_clear_staged_files (void);
|
||||
|
||||
#endif
|
||||
+16
-14
@@ -70,7 +70,7 @@ static GtkWidget *entry_nick1;
|
||||
static GtkWidget *entry_nick2;
|
||||
static GtkWidget *entry_nick3;
|
||||
static GtkWidget *entry_guser;
|
||||
/* static GtkWidget *entry_greal; */
|
||||
static GtkWidget *entry_greal;
|
||||
|
||||
enum {
|
||||
SERVER_TREE,
|
||||
@@ -1650,7 +1650,7 @@ servlist_savegui (void)
|
||||
sp = strchr (prefs.hex_irc_user_name, ' ');
|
||||
if (sp)
|
||||
sp[0] = 0; /* spaces will break the login */
|
||||
/* strcpy (prefs.hex_irc_real_name, gtk_entry_get_text (GTK_ENTRY (entry_greal))); */
|
||||
safe_strcpy (prefs.hex_irc_real_name, gtk_entry_get_text (GTK_ENTRY (entry_greal)), sizeof(prefs.hex_irc_real_name));
|
||||
servlist_save ();
|
||||
if (!save_config ())
|
||||
fe_message (_("Could not save zoitechat.conf."), FE_MSG_WARN);
|
||||
@@ -2809,12 +2809,12 @@ servlist_open_networks (void)
|
||||
GtkWidget *label4;
|
||||
GtkWidget *label5;
|
||||
GtkWidget *label6;
|
||||
/* GtkWidget *label7; */
|
||||
GtkWidget *label7;
|
||||
GtkWidget *entry1;
|
||||
GtkWidget *entry2;
|
||||
GtkWidget *entry3;
|
||||
GtkWidget *entry4;
|
||||
/* GtkWidget *entry5; */
|
||||
GtkWidget *entry5;
|
||||
GtkWidget *vbox2;
|
||||
GtkWidget *label1;
|
||||
GtkWidget *table4;
|
||||
@@ -2900,12 +2900,12 @@ servlist_open_networks (void)
|
||||
gtk_widget_set_halign (label6, GTK_ALIGN_START);
|
||||
gtk_widget_set_valign (label6, GTK_ALIGN_CENTER);
|
||||
|
||||
/* label7 = gtk_label_new_with_mnemonic (_("Rea_l name:"));
|
||||
label7 = gtk_label_new_with_mnemonic (_("Rea_l name:"));
|
||||
gtk_widget_show (label7);
|
||||
gtk_table_attach (GTK_TABLE (table1), label7, 0, 1, 4, 5,
|
||||
(GtkAttachOptions) (GTK_FILL),
|
||||
(GtkAttachOptions) (0), 0, 0);
|
||||
*/
|
||||
servlist_table_attach (table1, label7, 0, 1, 4, 5,
|
||||
FALSE, FALSE,
|
||||
SERVLIST_ALIGN_START, SERVLIST_ALIGN_CENTER,
|
||||
0, 0);
|
||||
|
||||
entry_nick1 = entry1 = gtk_entry_new ();
|
||||
gtk_entry_set_text (GTK_ENTRY (entry1), prefs.hex_irc_nick1);
|
||||
@@ -2939,12 +2939,14 @@ servlist_open_networks (void)
|
||||
SERVLIST_ALIGN_FILL, SERVLIST_ALIGN_CENTER,
|
||||
0, 0);
|
||||
|
||||
/* entry_greal = entry5 = gtk_entry_new ();
|
||||
entry_greal = entry5 = gtk_entry_new ();
|
||||
gtk_entry_set_max_length (GTK_ENTRY (entry5), sizeof prefs.hex_irc_real_name - 1);
|
||||
gtk_entry_set_text (GTK_ENTRY (entry5), prefs.hex_irc_real_name);
|
||||
gtk_widget_show (entry5);
|
||||
gtk_table_attach (GTK_TABLE (table1), entry5, 1, 2, 4, 5,
|
||||
(GtkAttachOptions) (GTK_EXPAND | GTK_FILL),
|
||||
(GtkAttachOptions) (0), 0, 0); */
|
||||
servlist_table_attach (table1, entry5, 1, 2, 4, 5,
|
||||
TRUE, FALSE,
|
||||
SERVLIST_ALIGN_FILL, SERVLIST_ALIGN_CENTER,
|
||||
0, 0);
|
||||
|
||||
vbox2 = gtkutil_box_new (GTK_ORIENTATION_VERTICAL, FALSE, 0);
|
||||
gtk_widget_show (vbox2);
|
||||
@@ -3098,7 +3100,7 @@ button_connect = gtkutil_button (hbuttonbox1, ICON_SERVLIST_CONNECT, NULL,
|
||||
|
||||
gtk_label_set_mnemonic_widget (GTK_LABEL (label3), entry1);
|
||||
gtk_label_set_mnemonic_widget (GTK_LABEL (label6), entry4);
|
||||
/* gtk_label_set_mnemonic_widget (GTK_LABEL (label7), entry5); */
|
||||
gtk_label_set_mnemonic_widget (GTK_LABEL (label7), entry5);
|
||||
|
||||
gtk_widget_grab_focus (networks_tree);
|
||||
gtk_widget_grab_default (button_close);
|
||||
|
||||
+1025
-355
File diff suppressed because it is too large.
Load diff
@@ -23,5 +23,10 @@
|
||||
#include "theme/theme-manager.h"
|
||||
|
||||
void setup_apply_real (const ThemeChangedEvent *event);
|
||||
void setup_open (void);
|
||||
/* Stable, untranslated page IDs allow existing menu entries to deep-link. */
|
||||
void setup_open_page (const char *id);
|
||||
/* Reveal an existing editor, including any nested menu selector, on errors. */
|
||||
void setup_reveal_widget (GtkWidget *widget);
|
||||
|
||||
#endif
|
||||
@@ -215,7 +215,22 @@ initialize_enchant (void)
|
||||
#endif
|
||||
};
|
||||
|
||||
for (i = 0; i < G_N_ELEMENTS(libnames); ++i)
|
||||
#ifdef __APPLE__
|
||||
const char *bundled_enchant = g_getenv ("ZOITECHAT_ENCHANT_LIBRARY");
|
||||
if (bundled_enchant && *bundled_enchant)
|
||||
{
|
||||
enchant = g_module_open (bundled_enchant, 0);
|
||||
if (enchant)
|
||||
{
|
||||
void (*set_prefix) (const char *) = NULL;
|
||||
const char *prefix = g_getenv ("ZOITECHAT_ENCHANT_PREFIX");
|
||||
if (prefix && g_module_symbol (enchant, "enchant_set_prefix_dir", (gpointer *) &set_prefix))
|
||||
set_prefix (prefix);
|
||||
have_enchant = TRUE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
for (i = 0; !have_enchant && i < G_N_ELEMENTS(libnames); ++i)
|
||||
{
|
||||
enchant = g_module_open(libnames[i], 0);
|
||||
if (enchant)
|
||||
@@ -913,6 +928,10 @@ sexy_spell_entry_init(SexySpellEntry *entry)
|
||||
entry->priv = g_new0(SexySpellEntryPriv, 1);
|
||||
|
||||
entry->priv->dict_hash = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
|
||||
entry->priv->attr_list = pango_attr_list_new();
|
||||
/* An untouched input still needs an empty word list. Loading the first
|
||||
* working dictionary on Save can recheck it before any changed signal. */
|
||||
entry->priv->words = g_new0(gchar *, 1);
|
||||
|
||||
if (have_enchant)
|
||||
{
|
||||
@@ -924,8 +943,6 @@ sexy_spell_entry_init(SexySpellEntry *entry)
|
||||
sexy_spell_entry_activate_default_languages(entry);
|
||||
}
|
||||
|
||||
entry->priv->attr_list = pango_attr_list_new();
|
||||
|
||||
entry->priv->checked = TRUE;
|
||||
entry->priv->parseattr = TRUE;
|
||||
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
/* Native preferences editor regressions. GPL-2.0-or-later. */
|
||||
#include "../../common/zoitechat.h"
|
||||
#include <glib/gstdio.h>
|
||||
#include "../../common/fe.h"
|
||||
#include "../../common/util.h"
|
||||
#include "../preferences-pages.h"
|
||||
#include "../theme/theme-access.h"
|
||||
#include "../notifications/notification-backend.h"
|
||||
|
||||
static gboolean gtk_available, notifications_available;
|
||||
static int notification_calls, beep_calls, file_calls, sound_changes;
|
||||
static char *played_file, *opened_page;
|
||||
static char *opened_folder, *preview_mask;
|
||||
|
||||
/* External effects are recorded; tests drive the real native page widgets. */
|
||||
int notification_backend_supported (void) { return notifications_available; }
|
||||
void notification_backend_show (const char *title, const char *text) { notification_calls++; }
|
||||
void fe_beep (session *sess) { beep_calls++; }
|
||||
void sound_play (const char *file, gboolean quiet)
|
||||
{
|
||||
file_calls++;
|
||||
g_free (played_file);
|
||||
played_file = g_strdup (file);
|
||||
}
|
||||
void setup_open_page (const char *id)
|
||||
{
|
||||
g_free (opened_page);
|
||||
opened_page = g_strdup (id);
|
||||
}
|
||||
void setup_reveal_widget (GtkWidget *widget) { }
|
||||
void fe_open_url (const char *url)
|
||||
{
|
||||
g_free (opened_folder);
|
||||
opened_folder = g_strdup (url);
|
||||
}
|
||||
char *log_build_pathname (const char *mask, const char *config_dir,
|
||||
const char *server, const char *channel,
|
||||
const char *network, time_t now)
|
||||
{
|
||||
/* Record the resolver input and return a sentinel path, so these UI
|
||||
* tests verify the displayed result comes from the resolver. */
|
||||
g_free (preview_mask);
|
||||
preview_mask = g_strdup (mask);
|
||||
return g_build_filename (config_dir, "resolved-example.log", NULL);
|
||||
}
|
||||
gboolean theme_get_color (ThemeSemanticToken token, GdkRGBA *rgba)
|
||||
{
|
||||
return gdk_rgba_parse (rgba, token == THEME_TOKEN_TEXT_BACKGROUND ? "#20242a" : "#eeeeee");
|
||||
}
|
||||
int get_stamp_str (char *format, time_t stamp, char **result)
|
||||
{
|
||||
GDateTime *date = g_date_time_new_from_unix_local (stamp);
|
||||
*result = g_date_time_format (date, format);
|
||||
g_date_time_unref (date);
|
||||
return *result ? strlen (*result) : 0;
|
||||
}
|
||||
|
||||
static void changed (gpointer data) { sound_changes++; }
|
||||
|
||||
static GtkWidget *
|
||||
find_widget (GtkWidget *root, const char *name)
|
||||
{
|
||||
GtkWidget *found = NULL;
|
||||
if (!strcmp (gtk_widget_get_name (root), name)) return root;
|
||||
if (GTK_IS_BUTTON (root) && !g_strcmp0 (gtk_button_get_label (GTK_BUTTON (root)), name)) return root;
|
||||
if (!GTK_IS_CONTAINER (root)) return NULL;
|
||||
GList *children = gtk_container_get_children (GTK_CONTAINER (root));
|
||||
for (GList *i = children; i && !found; i = i->next) found = find_widget (i->data, name);
|
||||
g_list_free (children);
|
||||
return found;
|
||||
}
|
||||
|
||||
static GtkWidget *control (GtkWidget *root, const char *name)
|
||||
{
|
||||
GtkWidget *widget = find_widget (root, name);
|
||||
g_assert_nonnull (widget);
|
||||
return widget;
|
||||
}
|
||||
|
||||
static void
|
||||
destroy_page (GtkWidget *page)
|
||||
{
|
||||
g_object_ref_sink (page);
|
||||
gtk_widget_destroy (page);
|
||||
g_object_unref (page);
|
||||
}
|
||||
|
||||
static void
|
||||
defaults (struct zoitechatprefs *prefs)
|
||||
{
|
||||
memset (prefs, 0, sizeof *prefs);
|
||||
g_strlcpy (prefs->hex_text_font, "Monospace 11", sizeof prefs->hex_text_font);
|
||||
#ifdef WIN32
|
||||
g_strlcpy (prefs->hex_text_font_main, "Monospace 11", sizeof prefs->hex_text_font_main);
|
||||
#endif
|
||||
g_strlcpy (prefs->hex_stamp_text_format, "[%H:%M:%S] ", sizeof prefs->hex_stamp_text_format);
|
||||
prefs->hex_gui_tab_layout = 2;
|
||||
prefs->hex_gui_tab_pos = 3;
|
||||
prefs->hex_text_indent = 1;
|
||||
prefs->hex_text_show_marker = 1;
|
||||
prefs->hex_stamp_text = 1;
|
||||
}
|
||||
|
||||
static void
|
||||
test_open_preserves_values (void)
|
||||
{
|
||||
struct zoitechatprefs prefs, original;
|
||||
GtkWidget *page;
|
||||
char *sound = NULL;
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs);
|
||||
/* Unknown legacy values and formats must survive merely visiting a page. */
|
||||
prefs.hex_gui_tab_layout = 1;
|
||||
prefs.hex_gui_tab_pos = 7;
|
||||
prefs.hex_gui_tab_trunc = 123;
|
||||
prefs.hex_gui_tab_newtofront = 9;
|
||||
prefs.hex_text_wrap_align = 9;
|
||||
g_strlcpy (prefs.hex_stamp_text_format, "时刻 %H:%M ", sizeof prefs.hex_stamp_text_format);
|
||||
original = prefs;
|
||||
page = preferences_text_page_new (&prefs, NULL);
|
||||
g_assert_cmpmem (&prefs, sizeof prefs, &original, sizeof original);
|
||||
destroy_page (page);
|
||||
page = preferences_tabs_page_new (&prefs);
|
||||
g_assert_cmpmem (&prefs, sizeof prefs, &original, sizeof original);
|
||||
destroy_page (page);
|
||||
page = preferences_notifications_page_new (&prefs, 0, &sound, changed, NULL);
|
||||
g_assert_cmpmem (&prefs, sizeof prefs, &original, sizeof original);
|
||||
g_assert_null (sound);
|
||||
destroy_page (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_timestamp_and_dependencies (void)
|
||||
{
|
||||
struct zoitechatprefs prefs;
|
||||
GtkWidget *page, *style, *format;
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs);
|
||||
g_strlcpy (prefs.hex_stamp_text_format, "Custom %H:%M ", sizeof prefs.hex_stamp_text_format);
|
||||
page = preferences_text_page_new (&prefs, NULL);
|
||||
style = control (page, "timestamp-style"); format = control (page, "timestamp-format");
|
||||
g_assert_cmpstr (gtk_combo_box_get_active_id (GTK_COMBO_BOX (style)), ==, "custom");
|
||||
gtk_combo_box_set_active_id (GTK_COMBO_BOX (style), "%H:%M ");
|
||||
g_assert_cmpstr (prefs.hex_stamp_text_format, ==, "%H:%M ");
|
||||
g_assert_false (gtk_widget_is_sensitive (format));
|
||||
gtk_combo_box_set_active_id (GTK_COMBO_BOX (style), "custom");
|
||||
g_assert_cmpstr (prefs.hex_stamp_text_format, ==, "Custom %H:%M ");
|
||||
gtk_entry_set_text (GTK_ENTRY (format), "[%H:%M] ");
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "timestamps")), FALSE);
|
||||
g_assert_false (gtk_widget_is_sensitive (format));
|
||||
g_assert_cmpstr (prefs.hex_stamp_text_format, ==, "[%H:%M] ");
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "indent-nicks")), FALSE);
|
||||
g_assert_true (gtk_widget_is_sensitive (control (page, "wrap-alignment")));
|
||||
gtk_combo_box_set_active (GTK_COMBO_BOX (control (page, "wrap-alignment")), 2);
|
||||
g_assert_cmpint (prefs.hex_text_wrap_align, ==, 2);
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "indent-nicks")), TRUE);
|
||||
g_assert_false (gtk_widget_is_sensitive (control (page, "wrap-alignment")));
|
||||
g_assert_cmpint (prefs.hex_text_wrap_align, ==, 2);
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "inline-modes")), TRUE);
|
||||
g_assert_false (gtk_widget_is_sensitive (control (page, "multiline-topic")));
|
||||
destroy_page (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_preview_updates (void)
|
||||
{
|
||||
struct zoitechatprefs prefs;
|
||||
GtkWidget *page;
|
||||
GtkTextBuffer *buffer;
|
||||
GtkTextIter start, end;
|
||||
char *text;
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs);
|
||||
page = preferences_text_page_new (&prefs, NULL);
|
||||
buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (control (page, "conversation-preview")));
|
||||
gtk_text_buffer_get_bounds (buffer, &start, &end);
|
||||
text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE);
|
||||
g_assert_nonnull (strstr (text, "[14:35:08]"));
|
||||
g_assert_nonnull (strstr (text, "Unread messages"));
|
||||
g_free (text);
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "timestamps")), FALSE);
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "unread-marker")), FALSE);
|
||||
gtk_text_buffer_get_bounds (buffer, &start, &end);
|
||||
text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE);
|
||||
g_assert_null (strstr (text, "14:35:08"));
|
||||
g_assert_null (strstr (text, "Unread messages"));
|
||||
g_free (text);
|
||||
destroy_page (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_layout_switching (void)
|
||||
{
|
||||
struct zoitechatprefs prefs;
|
||||
GtkWidget *page, *tree_control, *tab_control;
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs);
|
||||
prefs.hex_gui_tab_closebuttons = 1;
|
||||
prefs.hex_gui_tab_dots = 1;
|
||||
page = preferences_tabs_page_new (&prefs);
|
||||
tree_control = control (page, "tree-dots"); tab_control = control (page, "tab-close-buttons");
|
||||
g_assert_true (gtk_widget_is_visible (tree_control));
|
||||
g_assert_false (gtk_widget_is_visible (tab_control));
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "layout-tabs")), TRUE);
|
||||
g_assert_cmpint (prefs.hex_gui_tab_layout, ==, 0);
|
||||
g_assert_cmpint (prefs.hex_gui_tab_pos, ==, 6);
|
||||
g_assert_true (gtk_widget_is_visible (tab_control));
|
||||
g_assert_false (gtk_widget_is_visible (tree_control));
|
||||
g_assert_cmpint (prefs.hex_gui_tab_dots, ==, 1);
|
||||
gtk_combo_box_set_active (GTK_COMBO_BOX (control (page, "channel-placement")), 4);
|
||||
g_assert_cmpint (prefs.hex_gui_tab_pos, ==, 5);
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "layout-tree")), TRUE);
|
||||
g_assert_cmpint (prefs.hex_gui_tab_pos, ==, 3);
|
||||
g_assert_cmpint (prefs.hex_gui_tab_closebuttons, ==, 1);
|
||||
destroy_page (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_notification_mapping (void)
|
||||
{
|
||||
struct zoitechatprefs prefs;
|
||||
GtkWidget *page;
|
||||
char *sound = NULL;
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs);
|
||||
page = preferences_notifications_page_new (&prefs, 31, &sound, changed, NULL);
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "mention-desktop")), TRUE);
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "private-beep")), TRUE);
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "channel-tray")), TRUE);
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "private-attention")), TRUE);
|
||||
g_assert_cmpint (prefs.hex_input_balloon_hilight, ==, 1);
|
||||
g_assert_cmpint (prefs.hex_input_balloon_chans, ==, 0);
|
||||
g_assert_cmpint (prefs.hex_input_beep_priv, ==, 1);
|
||||
g_assert_cmpint (prefs.hex_input_beep_hilight, ==, 0);
|
||||
g_assert_cmpint (prefs.hex_input_tray_chans, ==, 1);
|
||||
g_assert_cmpint (prefs.hex_input_tray_priv, ==, 0);
|
||||
g_assert_cmpint (prefs.hex_input_flash_priv, ==, 1);
|
||||
g_assert_false (gtk_widget_is_sensitive (control (page, "tray-close")));
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "tray-enabled")), TRUE);
|
||||
g_assert_true (gtk_widget_is_sensitive (control (page, "tray-close")));
|
||||
destroy_page (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_shared_sound_and_effects (void)
|
||||
{
|
||||
struct zoitechatprefs prefs;
|
||||
GtkWidget *page;
|
||||
char *sound = g_strdup ("original.wav");
|
||||
if (!gtk_available) { g_free (sound); g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs);
|
||||
page = preferences_notifications_page_new (&prefs, 31, &sound, changed, NULL);
|
||||
sound_changes = beep_calls = file_calls = 0;
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "alert-sound-file")), "new.wav");
|
||||
g_assert_cmpstr (sound, ==, "new.wav");
|
||||
g_assert_cmpint (sound_changes, ==, 1);
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "Play")));
|
||||
g_assert_cmpstr (played_file, ==, "new.wav");
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "Use system sound")));
|
||||
g_assert_cmpstr (sound, ==, "");
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "Play")));
|
||||
g_assert_cmpint (beep_calls, ==, 1);
|
||||
g_free (sound); sound = g_strdup ("changed-in-sounds.wav");
|
||||
preferences_notifications_refresh_sound (page);
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (control (page, "alert-sound-file"))), ==, sound);
|
||||
g_assert_cmpint (sound_changes, ==, 2);
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "open-event-sounds")));
|
||||
g_assert_cmpstr (opened_page, ==, "sounds");
|
||||
destroy_page (page);
|
||||
g_free (sound);
|
||||
}
|
||||
|
||||
static void
|
||||
test_notification_test_safety (void)
|
||||
{
|
||||
struct zoitechatprefs prefs, original;
|
||||
GtkWidget *page;
|
||||
char *sound = NULL;
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs);
|
||||
prefs.hex_away_omit_alerts = prefs.hex_gui_focus_omitalerts = 1;
|
||||
original = prefs;
|
||||
notifications_available = TRUE; notification_calls = 0;
|
||||
page = preferences_notifications_page_new (&prefs, 31, &sound, changed, NULL);
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "notification-test")));
|
||||
g_assert_cmpint (notification_calls, ==, 1);
|
||||
g_assert_nonnull (strstr (gtk_label_get_text (GTK_LABEL (control (page, "notification-test-status"))), "marked away"));
|
||||
g_assert_cmpmem (&prefs, sizeof prefs, &original, sizeof original);
|
||||
notifications_available = FALSE;
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "notification-test")));
|
||||
g_assert_cmpint (notification_calls, ==, 1);
|
||||
destroy_page (page);
|
||||
page = preferences_notifications_page_new (&prefs, 0, &sound, changed, NULL);
|
||||
g_assert_false (gtk_widget_is_sensitive (control (page, "notification-test")));
|
||||
g_assert_false (gtk_widget_is_sensitive (control (page, "private-desktop")));
|
||||
g_assert_null (find_widget (page, "private-tray"));
|
||||
g_assert_null (find_widget (page, "private-attention"));
|
||||
g_assert_cmpmem (&prefs, sizeof prefs, &original, sizeof original);
|
||||
destroy_page (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_history_preserves_values (void)
|
||||
{
|
||||
struct zoitechatprefs prefs, original;
|
||||
GtkWidget *page;
|
||||
char *config, *history;
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs);
|
||||
config = g_dir_make_tmp ("zoitechat-history-ui-XXXXXX", NULL);
|
||||
history = g_build_filename (config, "scrollback", NULL);
|
||||
g_strlcpy (prefs.hex_irc_logmask, "custom-%s-%Y-%m.log", sizeof prefs.hex_irc_logmask);
|
||||
g_strlcpy (prefs.hex_stamp_log_format, "Custom %H:%M ", sizeof prefs.hex_stamp_log_format);
|
||||
prefs.hex_text_max_lines = 120000;
|
||||
prefs.hex_url_grabber_limit = 12000;
|
||||
original = prefs;
|
||||
page = preferences_history_page_new (&prefs, config);
|
||||
g_assert_cmpmem (&prefs, sizeof prefs, &original, sizeof original);
|
||||
g_assert_false (g_file_test (history, G_FILE_TEST_EXISTS));
|
||||
g_assert_false (gtk_widget_is_sensitive (control (page, "open-history-folder")));
|
||||
g_assert_cmpstr (gtk_combo_box_get_active_id (GTK_COMBO_BOX (control (page, "log-file-style"))), ==, "custom");
|
||||
char *expected = g_build_filename (config, "resolved-example.log", NULL);
|
||||
g_assert_cmpstr (gtk_label_get_text (GTK_LABEL (control (page, "log-example-file"))), ==, expected);
|
||||
g_free (expected);
|
||||
destroy_page (page);
|
||||
g_rmdir (config);
|
||||
g_free (config); g_free (history);
|
||||
}
|
||||
|
||||
static void
|
||||
test_history_presets_and_custom (void)
|
||||
{
|
||||
struct zoitechatprefs prefs;
|
||||
GtkWidget *page, *combo;
|
||||
char *mask, *expected;
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs); prefs.hex_irc_logging = 1;
|
||||
mask = g_build_filename (g_get_tmp_dir (), "100%%done", "%s-custom.log", NULL);
|
||||
g_strlcpy (prefs.hex_irc_logmask, mask, sizeof prefs.hex_irc_logmask);
|
||||
page = preferences_history_page_new (&prefs, g_get_tmp_dir ());
|
||||
combo = control (page, "log-file-style");
|
||||
gtk_combo_box_set_active (GTK_COMBO_BOX (combo), 1);
|
||||
expected = g_build_filename (g_get_tmp_dir (), "100%%done", "%n", "%Y-%m", "%c.log", NULL);
|
||||
g_assert_cmpstr (prefs.hex_irc_logmask, ==, expected);
|
||||
g_assert_cmpstr (preview_mask, ==, expected);
|
||||
gtk_combo_box_set_active_id (GTK_COMBO_BOX (combo), "custom");
|
||||
g_assert_cmpstr (prefs.hex_irc_logmask, ==, mask);
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "default-log-folder")));
|
||||
g_assert_cmpstr (prefs.hex_irc_logmask, ==, "%s-custom.log");
|
||||
g_free (expected); g_free (mask);
|
||||
destroy_page (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_history_dependencies_and_timestamps (void)
|
||||
{
|
||||
struct zoitechatprefs prefs;
|
||||
GtkWidget *page, *combo;
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs);
|
||||
g_strlcpy (prefs.hex_irc_logmask, "%n" G_DIR_SEPARATOR_S "%c.log", sizeof prefs.hex_irc_logmask);
|
||||
g_strlcpy (prefs.hex_stamp_log_format, "Time %H:%M ", sizeof prefs.hex_stamp_log_format);
|
||||
prefs.hex_stamp_log = 1;
|
||||
prefs.hex_url_logging = 1;
|
||||
prefs.hex_url_grabber_limit = 91;
|
||||
page = preferences_history_page_new (&prefs, g_get_tmp_dir ());
|
||||
g_assert_false (gtk_widget_is_sensitive (control (page, "choose-log-folder")));
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "save-logs")), TRUE);
|
||||
g_assert_true (gtk_widget_is_sensitive (control (page, "choose-log-folder")));
|
||||
combo = control (page, "log-timestamp-style");
|
||||
gtk_combo_box_set_active_id (GTK_COMBO_BOX (combo), "%H:%M ");
|
||||
g_assert_cmpstr (prefs.hex_stamp_log_format, ==, "%H:%M ");
|
||||
g_assert_nonnull (strstr (gtk_label_get_text (GTK_LABEL (control (page, "log-example-line"))), "14:35"));
|
||||
gtk_combo_box_set_active_id (GTK_COMBO_BOX (combo), "custom");
|
||||
g_assert_cmpstr (prefs.hex_stamp_log_format, ==, "Time %H:%M ");
|
||||
g_assert_cmpstr (prefs.hex_stamp_text_format, ==, "[%H:%M:%S] ");
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "log-timestamps")), FALSE);
|
||||
g_assert_false (gtk_widget_is_sensitive (control (page, "log-timestamp-format")));
|
||||
g_assert_cmpstr (prefs.hex_stamp_log_format, ==, "Time %H:%M ");
|
||||
g_assert_false (gtk_widget_is_sensitive (control (page, "link-limit")));
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "collect-links")), TRUE);
|
||||
g_assert_true (gtk_widget_is_sensitive (control (page, "link-limit")));
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "collect-links")), FALSE);
|
||||
g_assert_cmpint (prefs.hex_url_grabber_limit, ==, 91);
|
||||
g_assert_cmpint (prefs.hex_url_logging, ==, 1);
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "open-data-folder")));
|
||||
g_assert_cmpstr (opened_folder, ==, g_get_tmp_dir ());
|
||||
destroy_page (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_history_validation (void)
|
||||
{
|
||||
struct zoitechatprefs prefs;
|
||||
GtkWidget *page;
|
||||
GError *error = NULL;
|
||||
GString *long_format = g_string_new ("");
|
||||
if (!gtk_available) { g_string_free (long_format, TRUE); g_test_skip ("GTK display unavailable"); return; }
|
||||
defaults (&prefs); prefs.hex_irc_logging = 1;
|
||||
g_strlcpy (prefs.hex_irc_logmask, "old-custom.log", sizeof prefs.hex_irc_logmask);
|
||||
page = preferences_history_page_new (&prefs, g_get_tmp_dir ());
|
||||
g_assert_true (preferences_history_page_stage (page, &error));
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "log-file-format")), "");
|
||||
g_assert_false (preferences_history_page_stage (page, &error));
|
||||
g_assert_error (error, G_FILE_ERROR, G_FILE_ERROR_INVAL); g_clear_error (&error);
|
||||
for (int i = 0; i < 140; i++) g_string_append (long_format, "é");
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "log-file-format")), long_format->str);
|
||||
g_assert_false (preferences_history_page_stage (page, &error));
|
||||
g_clear_error (&error);
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "log-file-format")), "corrected-%Y.log");
|
||||
g_assert_true (preferences_history_page_stage (page, &error));
|
||||
g_assert_no_error (error);
|
||||
destroy_page (page);
|
||||
g_string_free (long_format, TRUE);
|
||||
}
|
||||
|
||||
static GtkWidget *
|
||||
language_button (GtkWidget *root, const char *code)
|
||||
{
|
||||
GtkWidget *found = NULL;
|
||||
if (!g_strcmp0 (g_object_get_data (G_OBJECT (root), "language-code"), code)) return root;
|
||||
if (!GTK_IS_CONTAINER (root)) return NULL;
|
||||
GList *children = gtk_container_get_children (GTK_CONTAINER (root));
|
||||
for (GList *i = children; i && !found; i = i->next) found = language_button (i->data, code);
|
||||
g_list_free (children); return found;
|
||||
}
|
||||
|
||||
static void
|
||||
test_typing_preservation_and_languages (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
struct zoitechatprefs prefs, original;
|
||||
defaults (&prefs);
|
||||
prefs.hex_gui_input_spell = prefs.hex_gui_input_icon = 1;
|
||||
prefs.hex_completion_amount = 5;
|
||||
g_strlcpy (prefs.hex_text_spell_langs, "en_US, missing_XX", sizeof prefs.hex_text_spell_langs);
|
||||
g_strlcpy (prefs.hex_completion_suffix, ",", sizeof prefs.hex_completion_suffix);
|
||||
original = prefs;
|
||||
PreferencesSpellLanguage languages[] = {{"en_US", "English (United States)"}, {"en_CA", "English (Canada)"}};
|
||||
GtkWidget *page = preferences_typing_page_new (&prefs, gtk_entry_new (), languages, 2, NULL);
|
||||
g_assert_cmpmem (&prefs, sizeof prefs, &original, sizeof original);
|
||||
g_assert_true (gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON (language_button (page, "en_US"))));
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (language_button (page, "en_CA")), TRUE);
|
||||
g_assert_nonnull (strstr (prefs.hex_text_spell_langs, "missing_XX"));
|
||||
g_assert_nonnull (strstr (prefs.hex_text_spell_langs, "en_CA"));
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (language_button (page, "en_US")), FALSE);
|
||||
g_assert_null (strstr (prefs.hex_text_spell_langs, "en_US"));
|
||||
g_assert_nonnull (strstr (prefs.hex_text_spell_langs, "missing_XX"));
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "spelling-enabled")), FALSE);
|
||||
g_assert_false (gtk_widget_is_sensitive (language_button (page, "en_CA")));
|
||||
g_assert_nonnull (strstr (prefs.hex_text_spell_langs, "en_CA"));
|
||||
g_assert_false (gtk_widget_is_sensitive (control (page, "input-mode-icon")));
|
||||
gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (control (page, "input-nickname")), TRUE);
|
||||
g_assert_true (gtk_widget_is_sensitive (control (page, "input-mode-icon")));
|
||||
destroy_page (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_typing_completion_and_validation (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
struct zoitechatprefs prefs;
|
||||
defaults (&prefs); prefs.hex_completion_amount = 5;
|
||||
GtkWidget *page = preferences_typing_page_new (&prefs, gtk_entry_new (), NULL, 0, NULL);
|
||||
GtkWidget *result = control (page, "completion-example-input");
|
||||
gtk_entry_set_text (GTK_ENTRY (result), "Al");
|
||||
gtk_spin_button_set_value (GTK_SPIN_BUTTON (control (page, "completion-limit")), 0);
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "completion-suffix")), "✓");
|
||||
g_assert_cmpstr (prefs.hex_completion_suffix, ==, "✓");
|
||||
GError *error = NULL;
|
||||
g_assert_true (preferences_typing_page_stage (page, &error));
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "completion-suffix")), "😀");
|
||||
g_assert_false (preferences_typing_page_stage (page, &error));
|
||||
g_assert_cmpstr (prefs.hex_completion_suffix, ==, "✓");
|
||||
g_clear_error (&error);
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "completion-suffix")), ":");
|
||||
char *too_long = g_strnfill (64, 'a');
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "spelling-codes")), too_long);
|
||||
g_assert_false (preferences_typing_page_stage (page, &error));
|
||||
g_clear_error (&error); g_free (too_long);
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "spelling-codes")), "unavailable-code");
|
||||
g_assert_true (preferences_typing_page_stage (page, &error));
|
||||
g_assert_no_error (error);
|
||||
destroy_page (page);
|
||||
}
|
||||
|
||||
static GdkModifierType test_modifiers (GdkModifierType mods)
|
||||
{
|
||||
return mods & (GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_MOD1_MASK | GDK_META_MASK);
|
||||
}
|
||||
static void test_restore (gpointer data) { }
|
||||
|
||||
static GtkWidget *
|
||||
shortcut_fixture (GtkListStore **store)
|
||||
{
|
||||
*store = gtk_list_store_new (6, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_BOOLEAN);
|
||||
GtkTreeIter iter;
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
gtk_list_store_append (*store, &iter);
|
||||
gtk_list_store_set (*store, &iter, 0, "Ctrl+K", 1, "<Control>k", 2, i == 1 ? "Change Page" : "Run Command",
|
||||
3, i == 0 ? "\002" : i == 1 ? "2" : "echo original\\nsecond", 4, i == 1 ? "Relative-custom-value" : NULL, 5, i != 0, -1);
|
||||
}
|
||||
PreferencesShortcutAction actions[] = {{"Run Command", "Run a command"}, {"Change Page", "Change a page"}};
|
||||
return preferences_shortcuts_page_new (*store, actions, 2, test_modifiers, test_restore, NULL);
|
||||
}
|
||||
|
||||
static void
|
||||
test_shortcuts_filter_and_protection (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
GtkListStore *store;
|
||||
GtkWidget *page = shortcut_fixture (&store);
|
||||
GtkTreeIter iter;
|
||||
gtk_tree_model_get_iter_first (GTK_TREE_MODEL (store), &iter);
|
||||
char *data;
|
||||
gtk_tree_model_get (GTK_TREE_MODEL (store), &iter, 3, &data, -1);
|
||||
g_assert_cmpstr (data, ==, "\002"); g_free (data);
|
||||
g_assert_false (gtk_widget_get_sensitive (control (page, "shortcut-action")));
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "shortcut-remove")));
|
||||
g_assert_cmpint (gtk_tree_model_iter_n_children (GTK_TREE_MODEL (store), NULL), ==, 3);
|
||||
g_assert_nonnull (strstr (gtk_label_get_text (GTK_LABEL (control (page, "shortcut-shared"))), "3 bindings"));
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "shortcut-search")), "Relative-custom-value");
|
||||
g_signal_emit_by_name (control (page, "shortcut-search"), "search-changed");
|
||||
GtkTreeModel *filtered = gtk_tree_view_get_model (GTK_TREE_VIEW (control (page, "shortcuts-list")));
|
||||
g_assert_cmpint (gtk_tree_model_iter_n_children (filtered, NULL), ==, 1);
|
||||
g_assert_cmpint (gtk_tree_model_iter_n_children (GTK_TREE_MODEL (store), NULL), ==, 3);
|
||||
GtkTreePath *path = gtk_tree_path_new_from_indices (1, -1);
|
||||
preferences_shortcuts_reveal_path (page, path); gtk_tree_path_free (path);
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (control (page, "shortcut-data2"))), ==, "Relative-custom-value");
|
||||
gtk_combo_box_set_active_id (GTK_COMBO_BOX (control (page, "shortcut-action")), "Run Command");
|
||||
gtk_tree_model_iter_nth_child (GTK_TREE_MODEL (store), &iter, NULL, 1);
|
||||
gtk_tree_model_get (GTK_TREE_MODEL (store), &iter, 4, &data, -1);
|
||||
g_assert_cmpstr (data, ==, "Relative-custom-value"); g_free (data);
|
||||
destroy_page (page); g_object_unref (store);
|
||||
}
|
||||
|
||||
static void
|
||||
test_shortcuts_duplicate_and_record (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
GtkListStore *store;
|
||||
GtkWidget *page = shortcut_fixture (&store);
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "shortcut-duplicate")));
|
||||
g_assert_cmpint (gtk_tree_model_iter_n_children (GTK_TREE_MODEL (store), NULL), ==, 4);
|
||||
gtk_entry_set_text (GTK_ENTRY (control (page, "shortcut-data1")), "echo changed\\nnext");
|
||||
GtkTreeIter iter; char *data;
|
||||
gtk_tree_model_iter_nth_child (GTK_TREE_MODEL (store), &iter, NULL, 3);
|
||||
gtk_tree_model_get (GTK_TREE_MODEL (store), &iter, 3, &data, -1);
|
||||
g_assert_cmpstr (data, ==, "echo changed\\nnext"); g_free (data);
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "shortcut-record")));
|
||||
GtkWidget *dialog = NULL;
|
||||
GList *tops = gtk_window_list_toplevels ();
|
||||
for (GList *i = tops; i; i = i->next) if (!strcmp (gtk_widget_get_name (i->data), "shortcut-recorder")) dialog = i->data;
|
||||
g_list_free (tops); g_assert_nonnull (dialog);
|
||||
GtkWidget *view = control (dialog, "GtkTreeView");
|
||||
GList *cells = gtk_cell_layout_get_cells (GTK_CELL_LAYOUT (gtk_tree_view_get_column (GTK_TREE_VIEW (view), 0)));
|
||||
g_signal_emit_by_name (cells->data, "accel-edited", "0", GDK_KEY_Tab, GDK_SHIFT_MASK | GDK_LOCK_MASK, 23);
|
||||
g_list_free (cells);
|
||||
gtk_tree_model_get (GTK_TREE_MODEL (store), &iter, 1, &data, -1);
|
||||
guint key; GdkModifierType mods;
|
||||
gtk_accelerator_parse (data, &key, &mods);
|
||||
g_assert_cmpuint (key, ==, GDK_KEY_ISO_Left_Tab);
|
||||
g_assert_cmpuint (mods, ==, GDK_SHIFT_MASK); g_free (data);
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "shortcut-remove")));
|
||||
g_assert_cmpint (gtk_tree_model_iter_n_children (GTK_TREE_MODEL (store), NULL), ==, 3);
|
||||
destroy_page (page); g_object_unref (store);
|
||||
}
|
||||
|
||||
static void
|
||||
test_shortcuts_order_and_recorder_close (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
GtkListStore *store;
|
||||
GtkWidget *page = shortcut_fixture (&store);
|
||||
GtkWidget *window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
|
||||
g_object_ref_sink (window);
|
||||
gtk_container_add (GTK_CONTAINER (window), page);
|
||||
gtk_widget_show_all (window);
|
||||
GtkTreePath *path = gtk_tree_path_new_from_indices (2, -1);
|
||||
preferences_shortcuts_reveal_path (page, path); gtk_tree_path_free (path);
|
||||
GdkEventKey event = {0};
|
||||
event.type = GDK_KEY_PRESS; event.keyval = GDK_KEY_Up; event.state = GDK_SHIFT_MASK;
|
||||
gboolean handled = FALSE;
|
||||
g_signal_emit_by_name (control (page, "shortcuts-list"), "key-press-event", &event, &handled);
|
||||
g_assert_true (handled);
|
||||
GtkTreeIter iter; char *data;
|
||||
gtk_tree_model_iter_nth_child (GTK_TREE_MODEL (store), &iter, NULL, 1);
|
||||
gtk_tree_model_get (GTK_TREE_MODEL (store), &iter, 3, &data, -1);
|
||||
g_assert_cmpstr (data, ==, "echo original\\nsecond"); g_free (data);
|
||||
g_signal_emit_by_name (control (page, "shortcuts-list"), "key-press-event", &event, &handled);
|
||||
gtk_tree_model_get_iter_first (GTK_TREE_MODEL (store), &iter);
|
||||
gtk_tree_model_get (GTK_TREE_MODEL (store), &iter, 3, &data, -1);
|
||||
g_assert_cmpstr (data, ==, "\002"); g_free (data);
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "shortcut-record")));
|
||||
GList *tops = gtk_window_list_toplevels ();
|
||||
GtkWidget *dialog = NULL;
|
||||
for (GList *i = tops; i; i = i->next) if (!strcmp (gtk_widget_get_name (i->data), "shortcut-recorder")) dialog = i->data;
|
||||
g_list_free (tops); g_assert_nonnull (dialog);
|
||||
gtk_dialog_response (GTK_DIALOG (dialog), GTK_RESPONSE_CANCEL);
|
||||
gtk_tree_model_iter_nth_child (GTK_TREE_MODEL (store), &iter, NULL, 1);
|
||||
gtk_tree_model_get (GTK_TREE_MODEL (store), &iter, 1, &data, -1);
|
||||
g_assert_cmpstr (data, ==, "<Control>k"); g_free (data);
|
||||
gtk_button_clicked (GTK_BUTTON (control (page, "shortcut-record")));
|
||||
gtk_widget_destroy (window);
|
||||
g_object_unref (window); g_object_unref (store);
|
||||
}
|
||||
|
||||
int main (int argc, char **argv)
|
||||
{
|
||||
g_test_init (&argc, &argv, NULL);
|
||||
gtk_available = gtk_init_check (&argc, &argv);
|
||||
g_test_add_func ("/preferences/pages/open-preserves-values", test_open_preserves_values);
|
||||
g_test_add_func ("/preferences/pages/timestamps-and-dependencies", test_timestamp_and_dependencies);
|
||||
g_test_add_func ("/preferences/pages/conversation-preview", test_preview_updates);
|
||||
g_test_add_func ("/preferences/pages/layout-switching", test_layout_switching);
|
||||
g_test_add_func ("/preferences/pages/notification-mapping", test_notification_mapping);
|
||||
g_test_add_func ("/preferences/pages/shared-sound", test_shared_sound_and_effects);
|
||||
g_test_add_func ("/preferences/pages/notification-test", test_notification_test_safety);
|
||||
g_test_add_func ("/preferences/history/open-preserves-values", test_history_preserves_values);
|
||||
g_test_add_func ("/preferences/history/presets-and-custom", test_history_presets_and_custom);
|
||||
g_test_add_func ("/preferences/history/dependencies-and-timestamps", test_history_dependencies_and_timestamps);
|
||||
g_test_add_func ("/preferences/history/validation", test_history_validation);
|
||||
g_test_add_func ("/preferences/typing/languages-preserved", test_typing_preservation_and_languages);
|
||||
g_test_add_func ("/preferences/typing/completion-validation", test_typing_completion_and_validation);
|
||||
g_test_add_func ("/preferences/shortcuts/filter-and-protection", test_shortcuts_filter_and_protection);
|
||||
g_test_add_func ("/preferences/shortcuts/duplicate-and-record", test_shortcuts_duplicate_and_record);
|
||||
g_test_add_func ("/preferences/shortcuts/order-and-recorder-close", test_shortcuts_order_and_recorder_close);
|
||||
int result = g_test_run ();
|
||||
g_free (played_file); g_free (opened_page);
|
||||
g_free (opened_folder); g_free (preview_mask);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/* ZoiteChat
|
||||
* Copyright (C) 1998-2010 Peter Zelezny.
|
||||
* Copyright (C) 2009-2013 Berke Viktor.
|
||||
* Copyright (C) 2026 deepend-tildeclub.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
|
||||
*/
|
||||
|
||||
/* Regression coverage for saving multiple Preferences editors together. */
|
||||
#include "../preferences-persistence.h"
|
||||
#include "../../common/cfgfiles.h"
|
||||
#include "../theme/theme-runtime.h"
|
||||
#include <errno.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static char *test_directory;
|
||||
static gboolean fail_theme_prepare;
|
||||
static int rename_count, fail_install, fail_restore;
|
||||
|
||||
char *get_xdir (void) { return test_directory; }
|
||||
|
||||
static gboolean
|
||||
prepare (const char *name, const char *contents, char **path)
|
||||
{
|
||||
*path = g_build_filename (test_directory, name, NULL);
|
||||
return g_file_set_contents (*path, contents, -1, NULL);
|
||||
}
|
||||
|
||||
int save_config_prepare (char **path) { return prepare ("config.new", "new config", path); }
|
||||
gboolean theme_runtime_save_prepare (char **path)
|
||||
{
|
||||
if (fail_theme_prepare) return FALSE;
|
||||
return prepare ("theme.new", "new theme", path);
|
||||
}
|
||||
|
||||
static int
|
||||
checked_rename (const char *source, const char *destination)
|
||||
{
|
||||
rename_count++;
|
||||
if (rename_count == fail_install || rename_count == fail_restore)
|
||||
{
|
||||
errno = EACCES;
|
||||
return -1;
|
||||
}
|
||||
return g_rename (source, destination);
|
||||
}
|
||||
|
||||
/* Exercise replacement failures without relying on filesystem permissions. */
|
||||
#undef g_rename
|
||||
#define g_rename checked_rename
|
||||
#include "../preferences-persistence.c"
|
||||
#undef g_rename
|
||||
|
||||
static void
|
||||
put (const char *name, const char *contents)
|
||||
{
|
||||
char *path = g_build_filename (test_directory, name, NULL);
|
||||
g_assert_true (g_file_set_contents (path, contents, -1, NULL));
|
||||
g_free (path);
|
||||
}
|
||||
|
||||
static void
|
||||
assert_contents (const char *name, const char *expected)
|
||||
{
|
||||
char *path = g_build_filename (test_directory, name, NULL), *contents = NULL;
|
||||
g_assert_true (g_file_get_contents (path, &contents, NULL, NULL));
|
||||
g_assert_cmpstr (contents, ==, expected);
|
||||
g_free (contents);
|
||||
g_free (path);
|
||||
}
|
||||
|
||||
static void
|
||||
begin (void)
|
||||
{
|
||||
test_directory = g_dir_make_tmp ("zoitechat-preferences-XXXXXX", NULL);
|
||||
g_assert_nonnull (test_directory);
|
||||
rename_count = fail_install = fail_restore = 0;
|
||||
fail_theme_prepare = FALSE;
|
||||
put ("zoitechat.conf", "old config");
|
||||
put ("colors.conf", "old theme");
|
||||
put ("commands.conf", "old commands");
|
||||
preferences_persistence_stage_file ("commands.conf", "new commands");
|
||||
preferences_persistence_stage_file ("replace.conf", "new replacements");
|
||||
}
|
||||
|
||||
static int
|
||||
temporary_count (void)
|
||||
{
|
||||
GDir *directory = g_dir_open (test_directory, 0, NULL);
|
||||
const char *name;
|
||||
int count = 0;
|
||||
while ((name = g_dir_read_name (directory)))
|
||||
if (g_str_has_prefix (name, ".preferences-")) count++;
|
||||
g_dir_close (directory);
|
||||
return count;
|
||||
}
|
||||
|
||||
static void
|
||||
end (void)
|
||||
{
|
||||
GDir *directory = g_dir_open (test_directory, 0, NULL);
|
||||
const char *name;
|
||||
preferences_persistence_clear_staged_files ();
|
||||
while ((name = g_dir_read_name (directory)))
|
||||
{
|
||||
char *path = g_build_filename (test_directory, name, NULL);
|
||||
if (g_file_test (path, G_FILE_TEST_IS_DIR)) g_rmdir (path);
|
||||
else g_unlink (path);
|
||||
g_free (path);
|
||||
}
|
||||
g_dir_close (directory);
|
||||
g_rmdir (test_directory);
|
||||
g_free (test_directory);
|
||||
}
|
||||
|
||||
static void
|
||||
test_success (void)
|
||||
{
|
||||
PreferencesPersistenceResult result;
|
||||
begin ();
|
||||
result = preferences_persistence_save_all ();
|
||||
g_assert_true (result.success);
|
||||
assert_contents ("zoitechat.conf", "new config");
|
||||
assert_contents ("colors.conf", "new theme");
|
||||
assert_contents ("commands.conf", "new commands");
|
||||
assert_contents ("replace.conf", "new replacements");
|
||||
g_assert_cmpint (temporary_count (), ==, 0);
|
||||
end ();
|
||||
}
|
||||
|
||||
static void
|
||||
test_prepare_failure (void)
|
||||
{
|
||||
PreferencesPersistenceResult result;
|
||||
begin ();
|
||||
fail_theme_prepare = TRUE;
|
||||
result = preferences_persistence_save_all ();
|
||||
g_assert_false (result.success);
|
||||
g_assert_false (result.partial_failure);
|
||||
g_assert_true (result.theme_failed);
|
||||
assert_contents ("zoitechat.conf", "old config");
|
||||
assert_contents ("commands.conf", "old commands");
|
||||
g_assert_cmpint (rename_count, ==, 0);
|
||||
g_assert_cmpint (temporary_count (), ==, 0);
|
||||
end ();
|
||||
}
|
||||
|
||||
static void
|
||||
test_backup_failure (void)
|
||||
{
|
||||
PreferencesPersistenceResult result;
|
||||
char *path;
|
||||
begin ();
|
||||
path = g_build_filename (test_directory, "replace.conf", NULL);
|
||||
g_assert_cmpint (g_mkdir (path, 0700), ==, 0);
|
||||
g_free (path);
|
||||
result = preferences_persistence_save_all ();
|
||||
g_assert_false (result.success);
|
||||
g_assert_cmpstr (result.failed_file, ==, "replace.conf");
|
||||
assert_contents ("zoitechat.conf", "old config");
|
||||
assert_contents ("commands.conf", "old commands");
|
||||
g_assert_cmpint (rename_count, ==, 0);
|
||||
g_assert_cmpint (temporary_count (), ==, 0);
|
||||
end ();
|
||||
}
|
||||
|
||||
static void
|
||||
test_rollback_and_retry (void)
|
||||
{
|
||||
PreferencesPersistenceResult result;
|
||||
begin ();
|
||||
fail_install = 4;
|
||||
result = preferences_persistence_save_all ();
|
||||
g_assert_false (result.success);
|
||||
g_assert_false (result.partial_failure);
|
||||
g_assert_true (result.retry_possible);
|
||||
assert_contents ("zoitechat.conf", "old config");
|
||||
assert_contents ("colors.conf", "old theme");
|
||||
assert_contents ("commands.conf", "old commands");
|
||||
g_assert_cmpint (temporary_count (), ==, 0);
|
||||
fail_install = 0;
|
||||
result = preferences_persistence_save_all ();
|
||||
g_assert_true (result.success);
|
||||
assert_contents ("commands.conf", "new commands");
|
||||
end ();
|
||||
}
|
||||
|
||||
static void
|
||||
test_recovery_copy (void)
|
||||
{
|
||||
PreferencesPersistenceResult result;
|
||||
begin ();
|
||||
fail_install = 4;
|
||||
fail_restore = 5;
|
||||
result = preferences_persistence_save_all ();
|
||||
g_assert_false (result.success);
|
||||
g_assert_true (result.partial_failure);
|
||||
g_assert_cmpint (temporary_count (), ==, 1);
|
||||
assert_contents ("zoitechat.conf", "old config");
|
||||
assert_contents ("colors.conf", "old theme");
|
||||
end ();
|
||||
}
|
||||
|
||||
int main (int argc, char **argv)
|
||||
{
|
||||
g_test_init (&argc, &argv, NULL);
|
||||
g_test_add_func ("/preferences/save/success", test_success);
|
||||
g_test_add_func ("/preferences/save/prepare-failure", test_prepare_failure);
|
||||
g_test_add_func ("/preferences/save/backup-failure", test_backup_failure);
|
||||
g_test_add_func ("/preferences/save/rollback-retry", test_rollback_and_retry);
|
||||
g_test_add_func ("/preferences/save/recovery-copy", test_recovery_copy);
|
||||
return g_test_run ();
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/* Spell-entry dictionary reload regressions. GPL-2.0-or-later. */
|
||||
#include "../../common/zoitechat.h"
|
||||
|
||||
/* Exercise the actual widget with a deterministic dictionary provider. This
|
||||
* must also run on builders without Enchant or installed dictionaries. */
|
||||
#include "../sexy-spell-entry.c"
|
||||
|
||||
struct zoitechatprefs prefs;
|
||||
struct EnchantBroker { int unused; };
|
||||
struct EnchantDict { char *language; };
|
||||
static gboolean gtk_available, dictionaries_available;
|
||||
static int active_dicts, checked_words;
|
||||
|
||||
gboolean theme_get_color (ThemeSemanticToken token, GdkRGBA *color)
|
||||
{
|
||||
return gdk_rgba_parse (color, token == THEME_TOKEN_TEXT_BACKGROUND ? "#ffffff" : "#000000");
|
||||
}
|
||||
gboolean theme_get_mirc_color (unsigned int index, GdkRGBA *color)
|
||||
{
|
||||
return gdk_rgba_parse (color, "#000000");
|
||||
}
|
||||
void theme_get_widget_style_values_for_widget (GtkWidget *widget, ThemeWidgetStyleValues *values)
|
||||
{
|
||||
memset (values, 0, sizeof *values);
|
||||
gdk_rgba_parse (&values->background, "#ffffff");
|
||||
}
|
||||
void theme_palette_color_get_rgb16 (const GdkRGBA *color, guint16 *red, guint16 *green, guint16 *blue)
|
||||
{
|
||||
*red = color->red * 65535;
|
||||
*green = color->green * 65535;
|
||||
*blue = color->blue * 65535;
|
||||
}
|
||||
const char *gtkutil_icon_name_from_stock (const char *stock) { return stock; }
|
||||
GtkWidget *gtkutil_image_new_from_stock (const char *stock, GtkIconSize size)
|
||||
{
|
||||
return gtk_image_new_from_icon_name (stock, size);
|
||||
}
|
||||
|
||||
#ifdef HAVE_ISO_CODES
|
||||
void codetable_init (void) { }
|
||||
void codetable_free (void) { }
|
||||
void codetable_lookup (const gchar *code, const gchar **language, const gchar **country)
|
||||
{
|
||||
*language = code;
|
||||
*country = "";
|
||||
}
|
||||
#endif
|
||||
|
||||
static struct EnchantBroker *test_broker_new (void) { return g_new0 (struct EnchantBroker, 1); }
|
||||
static void test_broker_free (struct EnchantBroker *broker) { g_free (broker); }
|
||||
static void test_list_dicts (struct EnchantBroker *broker, EnchantDictDescribeFn describe, void *data)
|
||||
{
|
||||
if (dictionaries_available) describe ("en_US", "test", "test", "", data);
|
||||
}
|
||||
static struct EnchantDict *test_request_dict (struct EnchantBroker *broker, const char *const language)
|
||||
{
|
||||
if (!dictionaries_available || (strcmp (language, "en_US") && strcmp (language, "en"))) return NULL;
|
||||
struct EnchantDict *dict = g_new0 (struct EnchantDict, 1);
|
||||
dict->language = g_strdup (language);
|
||||
active_dicts++;
|
||||
return dict;
|
||||
}
|
||||
static void test_free_dict (struct EnchantBroker *broker, struct EnchantDict *dict)
|
||||
{
|
||||
g_free (dict->language);
|
||||
g_free (dict);
|
||||
active_dicts--;
|
||||
}
|
||||
static void test_describe_dict (struct EnchantDict *dict, EnchantDictDescribeFn describe, void *data)
|
||||
{
|
||||
describe (dict->language, "test", "test", "", data);
|
||||
}
|
||||
static void test_add_word (struct EnchantDict *dict, const char *const word, ssize_t length) { }
|
||||
static int test_check_word (struct EnchantDict *dict, const char *const word, ssize_t length)
|
||||
{
|
||||
checked_words++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static SexySpellEntry *
|
||||
new_input (gboolean available)
|
||||
{
|
||||
dictionaries_available = available;
|
||||
memset (&prefs, 0, sizeof prefs);
|
||||
g_strlcpy (prefs.hex_text_spell_langs, "missing_XX", sizeof prefs.hex_text_spell_langs);
|
||||
SexySpellEntry *entry = SEXY_SPELL_ENTRY (sexy_spell_entry_new ());
|
||||
g_object_ref_sink (entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
static void
|
||||
destroy_input (SexySpellEntry *entry)
|
||||
{
|
||||
gtk_widget_destroy (GTK_WIDGET (entry));
|
||||
g_object_unref (entry);
|
||||
g_assert_cmpint (active_dicts, ==, 0);
|
||||
}
|
||||
|
||||
static void
|
||||
test_first_dictionary_on_empty_input (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
SexySpellEntry *entry = new_input (FALSE);
|
||||
g_assert_null (entry->priv->dict_list);
|
||||
/* The real Save path: an untouched input, no old dictionary, then a newly
|
||||
* selected dictionary. Before the fix, the final recheck dereferenced NULL. */
|
||||
dictionaries_available = TRUE;
|
||||
g_strlcpy (prefs.hex_text_spell_langs, "en_US", sizeof prefs.hex_text_spell_langs);
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
sexy_spell_entry_deactivate_language (entry, NULL);
|
||||
sexy_spell_entry_activate_default_languages (entry);
|
||||
g_assert_true (sexy_spell_entry_language_is_active (entry, "en_US"));
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "");
|
||||
}
|
||||
destroy_input (entry);
|
||||
}
|
||||
|
||||
static void
|
||||
test_reload_preserves_draft (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
SexySpellEntry *entry = new_input (FALSE);
|
||||
const char *draft = "Hello café";
|
||||
gtk_entry_set_text (GTK_ENTRY (entry), draft);
|
||||
dictionaries_available = TRUE;
|
||||
g_strlcpy (prefs.hex_text_spell_langs, "en_US", sizeof prefs.hex_text_spell_langs);
|
||||
checked_words = 0;
|
||||
sexy_spell_entry_deactivate_language (entry, NULL);
|
||||
sexy_spell_entry_activate_default_languages (entry);
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, draft);
|
||||
g_assert_cmpint (checked_words, >, 0);
|
||||
/* Save-failure rollback and retry also reload the input dictionaries. */
|
||||
dictionaries_available = FALSE;
|
||||
sexy_spell_entry_deactivate_language (entry, NULL);
|
||||
sexy_spell_entry_activate_default_languages (entry);
|
||||
dictionaries_available = TRUE;
|
||||
sexy_spell_entry_activate_default_languages (entry);
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, draft);
|
||||
destroy_input (entry);
|
||||
}
|
||||
|
||||
static void
|
||||
test_empty_input_spelling_toggles (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_skip ("GTK display unavailable"); return; }
|
||||
SexySpellEntry *entry = new_input (TRUE);
|
||||
sexy_spell_entry_set_parse_attributes (entry, FALSE);
|
||||
sexy_spell_entry_set_checked (entry, FALSE);
|
||||
sexy_spell_entry_set_checked (entry, TRUE);
|
||||
sexy_spell_entry_set_parse_attributes (entry, TRUE);
|
||||
sexy_spell_entry_deactivate_language (entry, NULL);
|
||||
sexy_spell_entry_activate_default_languages (entry);
|
||||
g_assert_true (sexy_spell_entry_language_is_active (entry, "en"));
|
||||
destroy_input (entry);
|
||||
}
|
||||
|
||||
int main (int argc, char **argv)
|
||||
{
|
||||
g_test_init (&argc, &argv, NULL);
|
||||
gtk_available = gtk_init_check (&argc, &argv);
|
||||
if (gtk_available)
|
||||
{
|
||||
SexySpellEntryClass *klass = g_type_class_ref (SEXY_TYPE_SPELL_ENTRY);
|
||||
have_enchant = TRUE;
|
||||
klass->word_check = default_word_check;
|
||||
enchant_broker_init = test_broker_new;
|
||||
enchant_broker_free = test_broker_free;
|
||||
enchant_broker_list_dicts = test_list_dicts;
|
||||
enchant_broker_request_dict = test_request_dict;
|
||||
enchant_broker_free_dict = test_free_dict;
|
||||
enchant_dict_describe = test_describe_dict;
|
||||
enchant_dict_add_to_session = test_add_word;
|
||||
enchant_dict_check = test_check_word;
|
||||
}
|
||||
g_test_add_func ("/spell-entry/first-dictionary-empty-input", test_first_dictionary_on_empty_input);
|
||||
g_test_add_func ("/spell-entry/reload-preserves-draft", test_reload_preserves_draft);
|
||||
g_test_add_func ("/spell-entry/empty-input-spelling-toggles", test_empty_input_spelling_toggles);
|
||||
return g_test_run ();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+248
-57
@@ -38,6 +38,8 @@
|
||||
#include "theme/theme-access.h"
|
||||
#include "theme/theme-manager.h"
|
||||
#include "textgui.h"
|
||||
#include "setup.h"
|
||||
#include "preferences-persistence.h"
|
||||
|
||||
#define ICON_TEXTEVENT_SAVE_AS "document-save-as"
|
||||
#define ICON_TEXTEVENT_OPEN "document-open"
|
||||
@@ -50,6 +52,10 @@ extern char *pntevts[];
|
||||
static GtkWidget *pevent_dialog = NULL, *pevent_dialog_twid,
|
||||
*pevent_dialog_list, *pevent_dialog_hlist;
|
||||
|
||||
static char *pevent_staged[NUM_XP];
|
||||
static char *pevent_original[NUM_XP];
|
||||
static gboolean pevent_pending;
|
||||
|
||||
#define PEVENT_THEME_LISTENER_ID_KEY "textgui.theme-listener-id"
|
||||
|
||||
enum
|
||||
@@ -92,7 +98,7 @@ PrintTextLine (xtext_buffer *xtbuf, unsigned char *text, int len, int indent, ti
|
||||
memcpy (new_text, stamp, stamp_size);
|
||||
g_free (stamp);
|
||||
memcpy (new_text + stamp_size, text, len);
|
||||
gtk_xtext_append (xtbuf, new_text, len + stamp_size, timet);
|
||||
gtk_xtext_append_wrap (xtbuf, new_text, len + stamp_size, timet, stamp_size);
|
||||
g_free (new_text);
|
||||
} else
|
||||
gtk_xtext_append (xtbuf, text, len, timet);
|
||||
@@ -148,10 +154,17 @@ PrintTextRaw (void *xtbuf, unsigned char *text, int indent, time_t stamp)
|
||||
}
|
||||
|
||||
static void
|
||||
pevent_dialog_close (GtkWidget *wid, gpointer arg)
|
||||
pevent_preferences_destroy (GtkWidget *widget, gpointer data)
|
||||
{
|
||||
pevent_dialog = NULL;
|
||||
pevent_save (NULL);
|
||||
int i;
|
||||
(void)widget; (void)data;
|
||||
pevent_dialog = pevent_dialog_list = pevent_dialog_hlist = pevent_dialog_twid = NULL;
|
||||
for (i = 0; i < NUM_XP; i++)
|
||||
{
|
||||
g_clear_pointer (&pevent_staged[i], g_free);
|
||||
g_clear_pointer (&pevent_original[i], g_free);
|
||||
}
|
||||
pevent_pending = FALSE;
|
||||
}
|
||||
|
||||
static void
|
||||
@@ -211,9 +224,14 @@ pevent_edited (GtkCellRendererText *render, gchar *pathstr, gchar *new_text, gpo
|
||||
char *out;
|
||||
int sig;
|
||||
|
||||
if (!gtkutil_treeview_get_selected (GTK_TREE_VIEW (pevent_dialog_list),
|
||||
&iter, ROW_COLUMN, &sig, -1))
|
||||
path = gtk_tree_path_new_from_string (pathstr);
|
||||
if (!gtk_tree_model_get_iter (model, &iter, path))
|
||||
{
|
||||
gtk_tree_path_free (path);
|
||||
return;
|
||||
}
|
||||
gtk_tree_path_free (path);
|
||||
gtk_tree_model_get (model, &iter, ROW_COLUMN, &sig, -1);
|
||||
|
||||
text = new_text;
|
||||
len = strlen (new_text);
|
||||
@@ -234,16 +252,11 @@ pevent_edited (GtkCellRendererText *render, gchar *pathstr, gchar *new_text, gpo
|
||||
return;
|
||||
}
|
||||
|
||||
path = gtk_tree_path_new_from_string (pathstr);
|
||||
gtk_tree_model_get_iter (model, &iter, path);
|
||||
gtk_list_store_set (GTK_LIST_STORE (model), &iter, TEXT_COLUMN, new_text, -1);
|
||||
gtk_tree_path_free (path);
|
||||
|
||||
g_free (pntevts_text[sig]);
|
||||
g_free (pntevts[sig]);
|
||||
|
||||
pntevts_text[sig] = g_strdup (text);
|
||||
pntevts[sig] = out;
|
||||
g_free (pevent_staged[sig]);
|
||||
pevent_staged[sig] = g_strdup (text);
|
||||
g_free (out);
|
||||
|
||||
out = g_malloc (len + 2);
|
||||
memcpy (out, text, len + 1);
|
||||
@@ -257,8 +270,7 @@ pevent_edited (GtkCellRendererText *render, gchar *pathstr, gchar *new_text, gpo
|
||||
/* Scroll to bottom */
|
||||
gtk_adjustment_set_value (xtext->adj, gtk_adjustment_get_upper (xtext->adj));
|
||||
|
||||
/* save this when we exit */
|
||||
prefs.save_pevents = 1;
|
||||
/* The Preferences Save action commits this staged template. */
|
||||
}
|
||||
|
||||
static void
|
||||
@@ -316,53 +328,222 @@ pevent_dialog_fill (GtkWidget *list)
|
||||
i--;
|
||||
gtk_list_store_insert_with_values (store, &iter, 0,
|
||||
EVENT_COLUMN, te[i].name,
|
||||
TEXT_COLUMN, pntevts_text[i],
|
||||
TEXT_COLUMN, pevent_staged[i],
|
||||
ROW_COLUMN, i, -1);
|
||||
}
|
||||
while (i != 0);
|
||||
}
|
||||
|
||||
static void
|
||||
pevent_save_req_cb (void *arg1, char *file)
|
||||
static char *
|
||||
pevent_preferences_serialize (void)
|
||||
{
|
||||
if (file)
|
||||
pevent_save (file);
|
||||
GString *text = g_string_new ("");
|
||||
int i;
|
||||
for (i = 0; i < NUM_XP; i++)
|
||||
g_string_append_printf (text, "event_name=%s\nevent_text=%s\n\n", te[i].name, pevent_staged[i]);
|
||||
return g_string_free (text, FALSE);
|
||||
}
|
||||
|
||||
static void
|
||||
pevent_save_cb (GtkWidget * wid, void *data)
|
||||
static gboolean
|
||||
pevent_preferences_validate (const char *text, int event, GError **error)
|
||||
{
|
||||
if (data)
|
||||
char *compiled = NULL;
|
||||
int maximum;
|
||||
if (strpbrk (text, "\r\n") || pevt_build_string (text, &compiled, &maximum) != 0)
|
||||
{
|
||||
gtkutil_file_req (NULL, _("Print Texts File"), pevent_save_req_cb, NULL,
|
||||
NULL, NULL, FRF_WRITE);
|
||||
return;
|
||||
g_free (compiled);
|
||||
g_set_error (error, G_FILE_ERROR, G_FILE_ERROR_INVAL, _("Invalid message format for %s."), te[event].name);
|
||||
return FALSE;
|
||||
}
|
||||
pevent_save (NULL);
|
||||
g_free (compiled);
|
||||
if (maximum > (te[event].num_args & 0x7f))
|
||||
{
|
||||
g_set_error (error, G_FILE_ERROR, G_FILE_ERROR_INVAL, _("%s only supports %d arguments."), te[event].name, te[event].num_args & 0x7f);
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void
|
||||
pevent_load_req_cb (void *arg1, char *file)
|
||||
gboolean
|
||||
pevent_preferences_import_text (const char *contents, GError **error)
|
||||
{
|
||||
if (file)
|
||||
char **lines;
|
||||
char *values[NUM_XP] = {0};
|
||||
int i, event = -1, matched = 0;
|
||||
gboolean success = FALSE;
|
||||
if (!pevent_dialog_list)
|
||||
{
|
||||
pevent_load (file);
|
||||
pevent_make_pntevts ();
|
||||
g_set_error_literal (error, G_FILE_ERROR, G_FILE_ERROR_INVAL, _("Open message formatting before importing templates."));
|
||||
return FALSE;
|
||||
}
|
||||
lines = g_strsplit (contents, "\n", -1);
|
||||
for (i = 0; lines[i]; i++)
|
||||
{
|
||||
char *line = lines[i];
|
||||
gsize length = strlen (line);
|
||||
int n;
|
||||
if (length && line[length - 1] == '\r') line[length - 1] = 0;
|
||||
if (g_str_has_prefix (line, "event_name="))
|
||||
{
|
||||
event = -1;
|
||||
for (n = 0; n < NUM_XP; n++)
|
||||
if (!strcmp (te[n].name, line + 11)) { event = n; break; }
|
||||
}
|
||||
else if (event >= 0 && g_str_has_prefix (line, "event_text="))
|
||||
{
|
||||
if (!pevent_preferences_validate (line + 11, event, error)) goto done;
|
||||
g_free (values[event]);
|
||||
values[event] = g_strdup (line + 11);
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
if (!matched)
|
||||
{
|
||||
g_set_error_literal (error, G_FILE_ERROR, G_FILE_ERROR_INVAL, _("No recognized text events were found in this file."));
|
||||
goto done;
|
||||
}
|
||||
for (i = 0; i < NUM_XP; i++)
|
||||
if (values[i])
|
||||
{
|
||||
g_free (pevent_staged[i]);
|
||||
pevent_staged[i] = values[i];
|
||||
values[i] = NULL;
|
||||
}
|
||||
pevent_dialog_fill (pevent_dialog_list);
|
||||
prefs.save_pevents = 1;
|
||||
success = TRUE;
|
||||
done:
|
||||
for (i = 0; i < NUM_XP; i++) g_free (values[i]);
|
||||
g_strfreev (lines);
|
||||
return success;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
pevent_preferences_import (const char *file, GError **error)
|
||||
{
|
||||
char *contents;
|
||||
if (!g_file_get_contents (file, &contents, NULL, error)) return FALSE;
|
||||
gboolean result = pevent_preferences_import_text (contents, error);
|
||||
g_free (contents);
|
||||
return result;
|
||||
}
|
||||
|
||||
typedef struct
|
||||
{
|
||||
GWeakRef page;
|
||||
gboolean exporting;
|
||||
} PeEventFileRequest;
|
||||
|
||||
static void
|
||||
pevent_file_request_free (gpointer data)
|
||||
{
|
||||
PeEventFileRequest *request = data;
|
||||
g_weak_ref_clear (&request->page);
|
||||
g_free (request);
|
||||
}
|
||||
|
||||
static void
|
||||
pevent_file_response (GtkNativeDialog *dialog, gint response, PeEventFileRequest *request)
|
||||
{
|
||||
GtkWidget *page = g_weak_ref_get (&request->page);
|
||||
if (page && page == pevent_dialog && response == GTK_RESPONSE_ACCEPT)
|
||||
{
|
||||
char *file = gtk_file_chooser_get_filename (GTK_FILE_CHOOSER (dialog));
|
||||
GError *error = NULL;
|
||||
if (file)
|
||||
{
|
||||
if (request->exporting)
|
||||
{
|
||||
char *contents = pevent_preferences_serialize ();
|
||||
g_file_set_contents (file, contents, -1, &error);
|
||||
g_free (contents);
|
||||
}
|
||||
else pevent_preferences_import (file, &error);
|
||||
if (error) { fe_message (error->message, FE_MSG_ERROR); g_clear_error (&error); }
|
||||
g_free (file);
|
||||
}
|
||||
}
|
||||
if (page) g_object_unref (page);
|
||||
g_object_unref (dialog);
|
||||
}
|
||||
|
||||
static void
|
||||
pevent_load_cb (GtkWidget * wid, void *data)
|
||||
pevent_file_open (GtkWidget *button, gpointer exporting)
|
||||
{
|
||||
gtkutil_file_req (NULL, _("Print Texts File"), pevent_load_req_cb, NULL, NULL, NULL, 0);
|
||||
GtkFileChooserNative *dialog;
|
||||
PeEventFileRequest *request = g_new0 (PeEventFileRequest, 1);
|
||||
(void)button;
|
||||
request->exporting = GPOINTER_TO_INT (exporting);
|
||||
g_weak_ref_init (&request->page, pevent_dialog);
|
||||
dialog = gtk_file_chooser_native_new (_("Text events file"),
|
||||
GTK_WINDOW (gtk_widget_get_toplevel (pevent_dialog)),
|
||||
request->exporting ? GTK_FILE_CHOOSER_ACTION_SAVE : GTK_FILE_CHOOSER_ACTION_OPEN,
|
||||
request->exporting ? _("_Export") : _("_Import"), _("_Cancel"));
|
||||
gtk_native_dialog_set_modal (GTK_NATIVE_DIALOG (dialog), TRUE);
|
||||
g_object_set_data_full (G_OBJECT (dialog), "request", request, pevent_file_request_free);
|
||||
g_signal_connect (dialog, "response", G_CALLBACK (pevent_file_response), request);
|
||||
gtk_native_dialog_show (GTK_NATIVE_DIALOG (dialog));
|
||||
}
|
||||
|
||||
static void
|
||||
pevent_ok_cb (GtkWidget * wid, void *data)
|
||||
gboolean
|
||||
pevent_preferences_stage (GError **error)
|
||||
{
|
||||
gtk_widget_destroy (pevent_dialog);
|
||||
int i;
|
||||
char *contents;
|
||||
pevent_pending = FALSE;
|
||||
if (!pevent_dialog) return TRUE;
|
||||
for (i = 0; i < NUM_XP; i++)
|
||||
{
|
||||
if (!pevent_preferences_validate (pevent_staged[i], i, error))
|
||||
{
|
||||
GtkTreeModel *model = gtk_tree_view_get_model (GTK_TREE_VIEW (pevent_dialog_list));
|
||||
GtkTreeIter iter;
|
||||
gboolean valid = gtk_tree_model_get_iter_first (model, &iter);
|
||||
setup_reveal_widget (pevent_dialog_list);
|
||||
while (valid)
|
||||
{
|
||||
int event;
|
||||
gtk_tree_model_get (model, &iter, ROW_COLUMN, &event, -1);
|
||||
if (event == i)
|
||||
{
|
||||
GtkTreePath *path = gtk_tree_model_get_path (model, &iter);
|
||||
gtk_widget_grab_focus (pevent_dialog_list);
|
||||
gtk_tree_view_set_cursor (GTK_TREE_VIEW (pevent_dialog_list), path, NULL, FALSE);
|
||||
gtk_tree_view_scroll_to_cell (GTK_TREE_VIEW (pevent_dialog_list), path, NULL, FALSE, 0, 0);
|
||||
gtk_tree_path_free (path);
|
||||
break;
|
||||
}
|
||||
valid = gtk_tree_model_iter_next (model, &iter);
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
if (g_strcmp0 (pevent_staged[i], pevent_original[i]) != 0) pevent_pending = TRUE;
|
||||
}
|
||||
if (!pevent_pending) return TRUE;
|
||||
for (i = 0; i < NUM_XP; i++)
|
||||
if (g_strcmp0 (pntevts_text[i], pevent_original[i]) != 0)
|
||||
{
|
||||
setup_reveal_widget (pevent_dialog_list);
|
||||
g_set_error_literal (error, G_FILE_ERROR, G_FILE_ERROR_AGAIN, _("Text events changed outside Preferences. Reopen Preferences before editing them."));
|
||||
return FALSE;
|
||||
}
|
||||
contents = pevent_preferences_serialize ();
|
||||
preferences_persistence_stage_file ("pevents.conf", contents);
|
||||
g_free (contents);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void
|
||||
pevent_preferences_commit (void)
|
||||
{
|
||||
int i;
|
||||
if (!pevent_pending) return;
|
||||
for (i = 0; i < NUM_XP; i++)
|
||||
{
|
||||
g_free (pntevts_text[i]);
|
||||
pntevts_text[i] = g_strdup (pevent_staged[i]);
|
||||
}
|
||||
pevent_make_pntevts ();
|
||||
prefs.save_pevents = 1;
|
||||
}
|
||||
|
||||
static void
|
||||
@@ -373,7 +554,7 @@ pevent_test_cb (GtkWidget * wid, GtkWidget * twid)
|
||||
|
||||
for (n = 0; n < NUM_XP; n++)
|
||||
{
|
||||
text = _(pntevts_text[n]);
|
||||
text = pevent_staged[n];
|
||||
len = strlen (text);
|
||||
|
||||
out = g_malloc (len + 2);
|
||||
@@ -485,22 +666,27 @@ pevent_hlist_treeview_new (GtkWidget *box)
|
||||
return view;
|
||||
}
|
||||
|
||||
void
|
||||
pevent_dialog_show ()
|
||||
static void
|
||||
pevent_preferences_preview_realize (GtkWidget *widget, gpointer data)
|
||||
{
|
||||
gtk_xtext_set_font (GTK_XTEXT (widget), prefs.hex_text_font);
|
||||
}
|
||||
|
||||
GtkWidget *
|
||||
pevent_preferences_create (void)
|
||||
{
|
||||
GtkWidget *vbox, *hbox, *wid, *pane, *preview_pane;
|
||||
XTextColor xtext_palette[XTEXT_COLS];
|
||||
|
||||
if (pevent_dialog)
|
||||
int i;
|
||||
vbox = gtk_box_new (GTK_ORIENTATION_VERTICAL, 10);
|
||||
pevent_dialog = vbox;
|
||||
for (i = 0; i < NUM_XP; i++)
|
||||
{
|
||||
mg_bring_tofront (pevent_dialog);
|
||||
return;
|
||||
pevent_original[i] = g_strdup (pntevts_text[i]);
|
||||
pevent_staged[i] = g_strdup (pntevts_text[i]);
|
||||
}
|
||||
|
||||
pevent_dialog =
|
||||
mg_create_generic_tab ("edit events", _("Edit Events"),
|
||||
TRUE, FALSE, pevent_dialog_close, NULL,
|
||||
600, 455, &vbox, 0);
|
||||
g_signal_connect (vbox, "destroy", G_CALLBACK (pevent_preferences_destroy), NULL);
|
||||
|
||||
preview_pane = gtk_paned_new (GTK_ORIENTATION_VERTICAL);
|
||||
gtk_box_pack_start (GTK_BOX (vbox), preview_pane, TRUE, TRUE, 0);
|
||||
@@ -523,7 +709,7 @@ pevent_dialog_show ()
|
||||
pevent_dialog_twid = gtk_xtext_new (xtext_palette, 0);
|
||||
gtk_widget_set_sensitive (pevent_dialog_twid, FALSE);
|
||||
gtk_container_add (GTK_CONTAINER (wid), pevent_dialog_twid);
|
||||
gtk_xtext_set_font (GTK_XTEXT (pevent_dialog_twid), prefs.hex_text_font);
|
||||
g_signal_connect (pevent_dialog_twid, "realize", G_CALLBACK (pevent_preferences_preview_realize), NULL);
|
||||
g_object_set_data (G_OBJECT (pevent_dialog), "xtext", pevent_dialog_twid);
|
||||
g_object_set_data (G_OBJECT (pevent_dialog), PEVENT_THEME_LISTENER_ID_KEY,
|
||||
GUINT_TO_POINTER (theme_listener_register ("textgui.events", pevent_dialog_theme_changed, pevent_dialog)));
|
||||
@@ -532,15 +718,20 @@ pevent_dialog_show ()
|
||||
hbox = gtk_button_box_new (GTK_ORIENTATION_HORIZONTAL);
|
||||
gtk_button_box_set_layout (GTK_BUTTON_BOX (hbox), GTK_BUTTONBOX_SPREAD);
|
||||
gtk_box_pack_start (GTK_BOX (vbox), hbox, FALSE, FALSE, 4);
|
||||
gtkutil_button (hbox, ICON_TEXTEVENT_SAVE_AS, NULL, pevent_save_cb,
|
||||
(void *) 1, _("Save As..."));
|
||||
gtkutil_button (hbox, ICON_TEXTEVENT_OPEN, NULL, pevent_load_cb,
|
||||
NULL, _("Load From..."));
|
||||
gtkutil_button (hbox, ICON_TEXTEVENT_SAVE_AS, NULL, pevent_file_open,
|
||||
(void *) 1, _("Export..."));
|
||||
gtkutil_button (hbox, ICON_TEXTEVENT_OPEN, NULL, pevent_file_open,
|
||||
NULL, _("Import..."));
|
||||
gtkutil_button (hbox, NULL, NULL, pevent_test_cb,
|
||||
pevent_dialog_twid, _("Test All"));
|
||||
gtkutil_button (hbox, ICON_TEXTEVENT_OK, NULL, pevent_ok_cb,
|
||||
NULL, _("OK"));
|
||||
|
||||
gtk_widget_show_all (pevent_dialog);
|
||||
pevent_dialog_theme_apply (pevent_dialog);
|
||||
return vbox;
|
||||
}
|
||||
|
||||
void
|
||||
pevent_dialog_show (void)
|
||||
{
|
||||
setup_open_page ("events");
|
||||
}
|
||||
@@ -22,5 +22,9 @@
|
||||
|
||||
void PrintTextRaw (void *xtbuf, unsigned char *text, int indent, time_t stamp);
|
||||
void pevent_dialog_show (void);
|
||||
GtkWidget *pevent_preferences_create (void);
|
||||
gboolean pevent_preferences_import_text (const char *contents, GError **error);
|
||||
gboolean pevent_preferences_stage (GError **error);
|
||||
void pevent_preferences_commit (void);
|
||||
|
||||
#endif
|
||||
@@ -112,6 +112,14 @@ theme_runtime_mode_has_user_colors (gboolean dark_mode)
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
theme_runtime_get_default_color (ThemeSemanticToken token, const ThemeGtkPaletteMap *map, GdkRGBA *color)
|
||||
{
|
||||
(void)map;
|
||||
*color = stub_light_colors[token];
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
theme_runtime_get_color (ThemeSemanticToken token, GdkRGBA *out_rgba)
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "../../fe-gtk.h"
|
||||
|
||||
#include "../theme-application.h"
|
||||
#include "../theme-css.h"
|
||||
#include "../theme-policy.h"
|
||||
#include "../../maingui.h"
|
||||
#include "../../../common/zoitechat.h"
|
||||
@@ -45,6 +46,13 @@ fe_message (char *msg, int flags)
|
||||
message_calls++;
|
||||
}
|
||||
|
||||
/* This test isolates input styling from the application's toplevel CSS. */
|
||||
char *
|
||||
theme_css_build_toplevel_classes (void)
|
||||
{
|
||||
return g_strdup ("");
|
||||
}
|
||||
|
||||
void
|
||||
theme_css_reload_input_style (gboolean enabled, const PangoFontDescription *font_desc)
|
||||
{
|
||||
|
||||
@@ -571,6 +571,31 @@ test_stale_theme_symlink_is_replaced (void)
|
||||
theme_gtk3_disable ();
|
||||
}
|
||||
|
||||
static void
|
||||
test_builtin_appearances_restore_desktop_theme (void)
|
||||
{
|
||||
if (!gtk_available) return;
|
||||
GtkSettings *settings = gtk_settings_get_default ();
|
||||
char *original, *name;
|
||||
gboolean dark;
|
||||
theme_gtk3_disable ();
|
||||
g_object_get (settings, "gtk-theme-name", &original, NULL);
|
||||
g_strlcpy (prefs.hex_gui_gtk3_theme, THEME_GTK3_BUILTIN_DARK, sizeof prefs.hex_gui_gtk3_theme);
|
||||
prefs.hex_gui_dark_mode = ZOITECHAT_DARK_MODE_DARK;
|
||||
g_assert_true (theme_gtk3_apply_current (NULL));
|
||||
g_object_get (settings, "gtk-theme-name", &name, "gtk-application-prefer-dark-theme", &dark, NULL);
|
||||
g_assert_cmpstr (name, ==, "Adwaita"); g_assert_true (dark); g_free (name);
|
||||
g_strlcpy (prefs.hex_gui_gtk3_theme, THEME_GTK3_BUILTIN_LIGHT, sizeof prefs.hex_gui_gtk3_theme);
|
||||
prefs.hex_gui_dark_mode = ZOITECHAT_DARK_MODE_LIGHT;
|
||||
g_assert_true (theme_gtk3_apply_current (NULL));
|
||||
g_object_get (settings, "gtk-application-prefer-dark-theme", &dark, NULL);
|
||||
g_assert_false (dark);
|
||||
prefs.hex_gui_gtk3_theme[0] = '\0'; prefs.hex_gui_dark_mode = ZOITECHAT_DARK_MODE_AUTO;
|
||||
g_assert_true (theme_gtk3_apply_current (NULL));
|
||||
g_object_get (settings, "gtk-theme-name", &name, NULL);
|
||||
g_assert_cmpstr (name, ==, original); g_free (name); g_free (original);
|
||||
}
|
||||
|
||||
int
|
||||
main (int argc, char **argv)
|
||||
{
|
||||
@@ -596,6 +621,7 @@ main (int argc, char **argv)
|
||||
if (!gtk_available)
|
||||
g_test_message ("Skipping GTK3 settings tests because GTK initialization failed");
|
||||
|
||||
g_test_add_func ("/theme/gtk3/builtin_appearances_restore_desktop", test_builtin_appearances_restore_desktop_theme);
|
||||
rc = g_test_run ();
|
||||
theme_gtk3_disable ();
|
||||
teardown_themes ();
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "../../../common/gtk3-theme-service.h"
|
||||
#include "../theme-gtk3.h"
|
||||
#include "../theme-manager.h"
|
||||
#include "../theme-runtime.h"
|
||||
|
||||
struct session *current_sess;
|
||||
struct session *current_tab;
|
||||
@@ -38,6 +39,61 @@ static char applied_theme_id[256];
|
||||
static ThemeGtk3Variant applied_variant;
|
||||
static gboolean removed_selected;
|
||||
|
||||
static gboolean custom_tokens[THEME_TOKEN_COUNT];
|
||||
static GdkRGBA custom_colors[THEME_TOKEN_COUNT];
|
||||
|
||||
gboolean
|
||||
theme_runtime_get_user_color (ThemeSemanticToken token, GdkRGBA *color)
|
||||
{
|
||||
if (!custom_tokens[token]) return FALSE;
|
||||
*color = custom_colors[token];
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
theme_runtime_get_default_color (ThemeSemanticToken token, const ThemeGtkPaletteMap *map, GdkRGBA *color)
|
||||
{
|
||||
(void)map;
|
||||
return gdk_rgba_parse (color, token == THEME_TOKEN_TEXT_BACKGROUND || token == THEME_TOKEN_SELECTION_FOREGROUND ? "#ffffff" :
|
||||
token == THEME_TOKEN_SELECTION_BACKGROUND ? "#336699" : "#000000");
|
||||
}
|
||||
|
||||
gboolean
|
||||
theme_get_default_color_for_widget (GtkWidget *widget, ThemeSemanticToken token, GdkRGBA *color)
|
||||
{
|
||||
(void)widget;
|
||||
theme_runtime_get_default_color (token, NULL, color);
|
||||
if (token == THEME_TOKEN_TEXT_BACKGROUND && prefs.hex_gui_dark_mode == ZOITECHAT_DARK_MODE_DARK)
|
||||
gdk_rgba_parse (color, "#181818");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void theme_runtime_reset_user_color (ThemeSemanticToken token) { custom_tokens[token] = FALSE; }
|
||||
void theme_runtime_user_set_color (ThemeSemanticToken token, const GdkRGBA *color)
|
||||
{ custom_tokens[token] = TRUE; custom_colors[token] = *color; }
|
||||
|
||||
gboolean theme_manager_apply_mode (unsigned int mode, gboolean *changed)
|
||||
{ (void)mode; if (changed) *changed = FALSE; return FALSE; }
|
||||
void theme_manager_apply_and_dispatch (unsigned int mode, ThemeChangedReason reasons, gboolean *changed)
|
||||
{ (void)mode; (void)reasons; if (changed) *changed = FALSE; }
|
||||
guint theme_listener_register (const char *id, ThemeChangedCallback callback, gpointer data)
|
||||
{ (void)id; (void)callback; (void)data; return 0; }
|
||||
void theme_listener_unregister (guint id) { (void)id; }
|
||||
gboolean pevent_preferences_import_text (const char *contents, GError **error)
|
||||
{ (void)contents; (void)error; return TRUE; }
|
||||
|
||||
void
|
||||
setup_open_page (const char *id)
|
||||
{
|
||||
(void)id;
|
||||
}
|
||||
|
||||
void
|
||||
setup_reveal_widget (GtkWidget *widget)
|
||||
{
|
||||
(void)widget;
|
||||
}
|
||||
|
||||
GtkWidget *
|
||||
gtkutil_box_new (GtkOrientation orientation, gboolean homogeneous, gint spacing)
|
||||
{
|
||||
@@ -106,6 +162,7 @@ theme_manager_set_token_color (unsigned int dark_mode, ThemeSemanticToken token,
|
||||
(void)token;
|
||||
(void)color;
|
||||
set_token_color_calls++;
|
||||
theme_runtime_user_set_color (token, color);
|
||||
if (changed)
|
||||
*changed = FALSE;
|
||||
}
|
||||
@@ -458,6 +515,154 @@ test_select_theme_does_not_pin_palette_tokens (void)
|
||||
gtk_widget_destroy (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_embedded_color_editor (void)
|
||||
{
|
||||
GtkWidget *window, *editor;
|
||||
theme_color_manager_ui *ui;
|
||||
theme_color_manager_row *row;
|
||||
GError *error = NULL;
|
||||
if (!gtk_available)
|
||||
{
|
||||
g_test_message ("GTK display not available");
|
||||
return;
|
||||
}
|
||||
memset (&prefs, 0, sizeof prefs);
|
||||
theme_preferences_stage_begin ();
|
||||
window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
|
||||
editor = theme_preferences_create_color_editor (GTK_WINDOW (window), NULL);
|
||||
gtk_container_add (GTK_CONTAINER (window), editor);
|
||||
ui = g_object_get_data (G_OBJECT (editor), "zoitechat-theme-color-manager");
|
||||
row = g_ptr_array_index (ui->rows, 0);
|
||||
set_token_color_calls = 0;
|
||||
theme_preferences_manager_entry_commit (row);
|
||||
g_assert_true (theme_preferences_color_editor_stage (editor, &error));
|
||||
g_assert_no_error (error);
|
||||
theme_preferences_stage_apply ();
|
||||
g_assert_cmpint (set_token_color_calls, ==, 0);
|
||||
gtk_entry_set_text (GTK_ENTRY (row->entry), "#123456");
|
||||
g_assert_true (theme_preferences_color_editor_stage (editor, &error));
|
||||
g_assert_no_error (error);
|
||||
/* A typed edit previews its token, then Save reapplies that token. */
|
||||
g_assert_cmpint (set_token_color_calls, ==, 1);
|
||||
theme_preferences_stage_apply ();
|
||||
g_assert_cmpint (set_token_color_calls, ==, 2);
|
||||
gtk_entry_set_text (GTK_ENTRY (row->entry), "invalid");
|
||||
g_assert_false (theme_preferences_color_editor_stage (editor, &error));
|
||||
g_assert_error (error, G_FILE_ERROR, G_FILE_ERROR_INVAL);
|
||||
g_clear_error (&error);
|
||||
gtk_widget_show_all (window);
|
||||
gtk_button_clicked (GTK_BUTTON (row->button));
|
||||
gtk_widget_destroy (window);
|
||||
theme_preferences_stage_discard ();
|
||||
}
|
||||
|
||||
static void
|
||||
test_appearance_schemes_and_cancel (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_message ("GTK display not available"); return; }
|
||||
memset (&prefs, 0, sizeof prefs); memset (custom_tokens, 0, sizeof custom_tokens);
|
||||
GdkRGBA original, restored;
|
||||
gdk_rgba_parse (&original, "#345678");
|
||||
theme_runtime_user_set_color (THEME_TOKEN_TEXT_FOREGROUND, &original);
|
||||
theme_preferences_stage_begin ();
|
||||
struct zoitechatprefs staged = prefs;
|
||||
GtkWidget *page = theme_preferences_create_page (NULL, &staged, NULL);
|
||||
theme_preferences_ui *appearance = g_object_get_data (G_OBJECT (page), "theme-preferences-ui");
|
||||
theme_color_manager_ui *ui = g_object_get_data (G_OBJECT (appearance->color_editor), "zoitechat-theme-color-manager");
|
||||
gtk_button_clicked (GTK_BUTTON (ui->scheme_buttons[2]));
|
||||
g_assert_true (theme_preferences_stage.changed);
|
||||
g_assert_true (theme_preferences_stage.staged_custom[THEME_TOKEN_TEXT_BACKGROUND]);
|
||||
gtk_button_clicked (GTK_BUTTON (appearance->style_buttons[2]));
|
||||
g_assert_cmpint (prefs.hex_gui_dark_mode, ==, ZOITECHAT_DARK_MODE_DARK);
|
||||
theme_preferences_stage_discard ();
|
||||
g_assert_cmpint (prefs.hex_gui_dark_mode, ==, ZOITECHAT_DARK_MODE_AUTO);
|
||||
g_assert_true (theme_runtime_get_user_color (THEME_TOKEN_TEXT_FOREGROUND, &restored));
|
||||
g_assert_true (gdk_rgba_equal (&original, &restored));
|
||||
for (int token = 0; token < THEME_TOKEN_COUNT; token++)
|
||||
if (token != THEME_TOKEN_TEXT_FOREGROUND) g_assert_false (custom_tokens[token]);
|
||||
gtk_widget_destroy (page);
|
||||
}
|
||||
|
||||
static void
|
||||
test_individual_reset_preserves_other_overrides (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_message ("GTK display not available"); return; }
|
||||
memset (&prefs, 0, sizeof prefs); memset (custom_tokens, 0, sizeof custom_tokens);
|
||||
theme_preferences_stage_begin ();
|
||||
GtkWidget *editor = theme_preferences_create_color_editor (NULL, NULL);
|
||||
theme_color_manager_ui *ui = g_object_get_data (G_OBJECT (editor), "zoitechat-theme-color-manager");
|
||||
gtk_button_clicked (GTK_BUTTON (ui->scheme_buttons[2]));
|
||||
theme_color_manager_row *row = g_ptr_array_index (ui->rows, THEME_TOKEN_TEXT_BACKGROUND);
|
||||
gtk_button_clicked (GTK_BUTTON (row->reset));
|
||||
g_assert_false (theme_preferences_stage.staged_custom[THEME_TOKEN_TEXT_BACKGROUND]);
|
||||
g_assert_true (theme_preferences_stage.staged_custom[THEME_TOKEN_TEXT_FOREGROUND]);
|
||||
g_assert_cmpstr (gtk_label_get_text (GTK_LABEL (row->status)), ==, "Theme");
|
||||
GdkRGBA light, dark;
|
||||
theme_preferences_staged_get_color (THEME_TOKEN_TEXT_BACKGROUND, &light);
|
||||
prefs.hex_gui_dark_mode = ZOITECHAT_DARK_MODE_DARK;
|
||||
theme_preferences_staged_get_color (THEME_TOKEN_TEXT_BACKGROUND, &dark);
|
||||
g_assert_false (gdk_rgba_equal (&light, &dark));
|
||||
theme_preferences_stage_commit ();
|
||||
g_assert_false (custom_tokens[THEME_TOKEN_TEXT_BACKGROUND]);
|
||||
g_assert_true (custom_tokens[THEME_TOKEN_TEXT_FOREGROUND]);
|
||||
gtk_widget_destroy (editor);
|
||||
}
|
||||
|
||||
static void
|
||||
test_contrast_suggestion_and_preview_navigation (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_message ("GTK display not available"); return; }
|
||||
memset (&prefs, 0, sizeof prefs); memset (custom_tokens, 0, sizeof custom_tokens);
|
||||
theme_preferences_stage_begin ();
|
||||
GtkWidget *editor = theme_preferences_create_color_editor (NULL, NULL);
|
||||
theme_color_manager_ui *ui = g_object_get_data (G_OBJECT (editor), "zoitechat-theme-color-manager");
|
||||
theme_color_manager_row *row = g_ptr_array_index (ui->rows, THEME_TOKEN_TEXT_FOREGROUND);
|
||||
GdkRGBA white, black, gray;
|
||||
gdk_rgba_parse (&white, "#ffffff"); gdk_rgba_parse (&black, "#000000"); gdk_rgba_parse (&gray, "#dddddd");
|
||||
g_assert_cmpfloat_with_epsilon (theme_preferences_contrast_ratio (&white, &black), 21.0, 0.001);
|
||||
theme_preferences_manager_row_commit (row, &gray);
|
||||
g_assert_true (gtk_widget_get_visible (ui->contrast_fix));
|
||||
gtk_button_clicked (GTK_BUTTON (ui->contrast_fix));
|
||||
theme_preferences_staged_get_color (THEME_TOKEN_TEXT_FOREGROUND, &gray);
|
||||
g_assert_cmpfloat (theme_preferences_contrast_ratio (&gray, &white), >=, 4.5);
|
||||
theme_preferences_manager_focus_color (ui, THEME_TOKEN_MIRC_12);
|
||||
g_assert_true (gtk_expander_get_expanded (GTK_EXPANDER (ui->advanced)));
|
||||
gtk_entry_set_text (GTK_ENTRY (row->entry), "invalid");
|
||||
theme_preferences_manager_entry_commit (row);
|
||||
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (row->entry)), ==, "invalid");
|
||||
GError *error = NULL;
|
||||
g_assert_false (theme_preferences_color_editor_stage (editor, &error));
|
||||
g_assert_error (error, G_FILE_ERROR, G_FILE_ERROR_INVAL); g_clear_error (&error);
|
||||
theme_preferences_stage_discard (); gtk_widget_destroy (editor);
|
||||
}
|
||||
|
||||
static void
|
||||
test_picker_cancel_restores_inheritance (void)
|
||||
{
|
||||
if (!gtk_available) { g_test_message ("GTK display not available"); return; }
|
||||
memset (&prefs, 0, sizeof prefs); memset (custom_tokens, 0, sizeof custom_tokens);
|
||||
theme_preferences_stage_begin ();
|
||||
GtkWidget *window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
|
||||
GtkWidget *editor = theme_preferences_create_color_editor (GTK_WINDOW (window), NULL);
|
||||
gtk_container_add (GTK_CONTAINER (window), editor);
|
||||
theme_color_manager_ui *ui = g_object_get_data (G_OBJECT (editor), "zoitechat-theme-color-manager");
|
||||
theme_color_manager_row *row = g_ptr_array_index (ui->rows, THEME_TOKEN_MIRC_12);
|
||||
gtk_button_clicked (GTK_BUTTON (row->button));
|
||||
GList *windows = gtk_window_list_toplevels ();
|
||||
GtkWidget *picker = NULL;
|
||||
for (GList *i = windows; i; i = i->next) if (GTK_IS_COLOR_CHOOSER_DIALOG (i->data)) picker = i->data;
|
||||
g_assert_nonnull (picker);
|
||||
GdkRGBA color; gdk_rgba_parse (&color, "#aabbcc");
|
||||
gtk_color_chooser_set_rgba (GTK_COLOR_CHOOSER (picker), &color);
|
||||
g_assert_true (theme_preferences_stage.staged_custom[row->token]);
|
||||
gtk_dialog_response (GTK_DIALOG (picker), GTK_RESPONSE_CANCEL);
|
||||
g_assert_false (theme_preferences_stage.staged_custom[row->token]);
|
||||
g_assert_false (custom_tokens[row->token]);
|
||||
g_list_free (windows);
|
||||
theme_preferences_stage_discard (); gtk_widget_destroy (window);
|
||||
}
|
||||
|
||||
int
|
||||
main (int argc, char **argv)
|
||||
{
|
||||
@@ -471,5 +676,10 @@ main (int argc, char **argv)
|
||||
test_select_none_resets_theme_and_applies);
|
||||
g_test_add_func ("/theme/preferences/gtk3_select_theme_does_not_pin_palette_tokens",
|
||||
test_select_theme_does_not_pin_palette_tokens);
|
||||
g_test_add_func ("/theme/preferences/embedded_color_editor", test_embedded_color_editor);
|
||||
g_test_add_func ("/theme/preferences/appearance_schemes_cancel", test_appearance_schemes_and_cancel);
|
||||
g_test_add_func ("/theme/preferences/individual_reset_inherits", test_individual_reset_preserves_other_overrides);
|
||||
g_test_add_func ("/theme/preferences/contrast_and_navigation", test_contrast_suggestion_and_preview_navigation);
|
||||
g_test_add_func ("/theme/preferences/picker_cancel_inherits", test_picker_cancel_restores_inheritance);
|
||||
return g_test_run ();
|
||||
}
|
||||
@@ -430,6 +430,36 @@ test_save_writes_only_custom_token_keys (void)
|
||||
g_free (cfg);
|
||||
}
|
||||
|
||||
static void
|
||||
test_reset_one_colour_restores_inheritance_after_reload (void)
|
||||
{
|
||||
GdkRGBA color;
|
||||
char *cfg;
|
||||
setup_temp_home ();
|
||||
theme_runtime_load ();
|
||||
gdk_rgba_parse (&color, "#456789");
|
||||
theme_runtime_user_set_color (THEME_TOKEN_TEXT_FOREGROUND, &color);
|
||||
theme_runtime_user_set_color (THEME_TOKEN_TEXT_BACKGROUND, &color);
|
||||
theme_runtime_reset_user_color (THEME_TOKEN_TEXT_BACKGROUND);
|
||||
theme_runtime_apply_mode (ZOITECHAT_DARK_MODE_LIGHT, NULL);
|
||||
g_assert_true (theme_runtime_save ());
|
||||
cfg = read_colors_conf ();
|
||||
g_assert_nonnull (strstr (cfg, "theme.mode.light.token.text_foreground"));
|
||||
g_assert_null (strstr (cfg, "theme.mode.light.token.text_background"));
|
||||
g_free (cfg);
|
||||
theme_runtime_load ();
|
||||
g_assert_false (theme_runtime_get_user_color (THEME_TOKEN_TEXT_BACKGROUND, &color));
|
||||
g_assert_true (theme_runtime_get_user_color (THEME_TOKEN_TEXT_FOREGROUND, &color));
|
||||
g_assert_cmpfloat_with_epsilon (color.red, 0x45 / 255.0, 0.0001);
|
||||
ThemeGtkPaletteMap map = { .enabled = TRUE };
|
||||
gdk_rgba_parse (&map.text_background, "#111111");
|
||||
theme_runtime_get_default_color (THEME_TOKEN_TEXT_BACKGROUND, &map, &color);
|
||||
g_assert_cmpfloat_with_epsilon (color.red, 0x11 / 255.0, 0.0001);
|
||||
gdk_rgba_parse (&map.text_background, "#eeeeee");
|
||||
theme_runtime_get_default_color (THEME_TOKEN_TEXT_BACKGROUND, &map, &color);
|
||||
g_assert_cmpfloat_with_epsilon (color.red, 0xee / 255.0, 0.0001);
|
||||
}
|
||||
|
||||
int
|
||||
main (int argc, char **argv)
|
||||
{
|
||||
@@ -450,5 +480,6 @@ main (int argc, char **argv)
|
||||
test_save_discard_unlinks_temp_file);
|
||||
g_test_add_func ("/theme/runtime/save_writes_only_custom_token_keys",
|
||||
test_save_writes_only_custom_token_keys);
|
||||
g_test_add_func ("/theme/runtime/reset_colour_persists_inheritance", test_reset_one_colour_restores_inheritance_after_reload);
|
||||
return g_test_run ();
|
||||
}
|
||||
@@ -206,6 +206,14 @@ theme_access_get_gtk_palette_map (GtkWidget *widget, ThemeGtkPaletteMap *out_map
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
theme_get_default_color_for_widget (GtkWidget *widget, ThemeSemanticToken token, GdkRGBA *color)
|
||||
{
|
||||
ThemeGtkPaletteMap map = { 0 };
|
||||
theme_access_get_gtk_palette_map (widget, &map);
|
||||
return theme_runtime_get_default_color (token, &map, color);
|
||||
}
|
||||
|
||||
gboolean
|
||||
theme_get_color (ThemeSemanticToken token, GdkRGBA *out_rgba)
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "theme-palette.h"
|
||||
#include "../xtext-color.h"
|
||||
|
||||
gboolean theme_get_default_color_for_widget (GtkWidget *widget, ThemeSemanticToken token, GdkRGBA *color);
|
||||
gboolean theme_get_color (ThemeSemanticToken token, GdkRGBA *out_rgba);
|
||||
gboolean theme_get_mirc_color (unsigned int mirc_index, GdkRGBA *out_rgba);
|
||||
gboolean theme_get_color_rgb16 (ThemeSemanticToken token, guint16 *red, guint16 *green, guint16 *blue);
|
||||
|
||||
@@ -1223,9 +1223,27 @@ theme_gtk3_init (void)
|
||||
gboolean
|
||||
theme_gtk3_apply_current (GError **error)
|
||||
{
|
||||
if (!strcmp (prefs.hex_gui_gtk3_theme, THEME_GTK3_BUILTIN_LIGHT) || !strcmp (prefs.hex_gui_gtk3_theme, THEME_GTK3_BUILTIN_DARK))
|
||||
{
|
||||
/* GTK ships Adwaita on every supported platform. Explicit Light/Dark
|
||||
* choices must not depend on the desktop theme providing both variants. */
|
||||
GtkSettings *settings = gtk_settings_get_default ();
|
||||
theme_gtk3_disable ();
|
||||
if (settings)
|
||||
settings_apply_property (settings, "gtk-theme-name", "Adwaita");
|
||||
settings_apply_for_variant (!strcmp (prefs.hex_gui_gtk3_theme, THEME_GTK3_BUILTIN_DARK) ?
|
||||
THEME_GTK3_VARIANT_PREFER_DARK : THEME_GTK3_VARIANT_PREFER_LIGHT);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if (!prefs.hex_gui_gtk3_theme[0])
|
||||
{
|
||||
theme_gtk3_disable ();
|
||||
/* Restoring a previous theme's settings must not undo an explicit
|
||||
* appearance choice, including when a settings notification re-enters. */
|
||||
settings_apply_for_variant (prefs.hex_gui_dark_mode == ZOITECHAT_DARK_MODE_AUTO ?
|
||||
THEME_GTK3_VARIANT_FOLLOW_SYSTEM : prefs.hex_gui_dark_mode == ZOITECHAT_DARK_MODE_DARK ?
|
||||
THEME_GTK3_VARIANT_PREFER_DARK : THEME_GTK3_VARIANT_PREFER_LIGHT);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
#include <glib.h>
|
||||
#include <gdk/gdk.h>
|
||||
|
||||
#define THEME_GTK3_BUILTIN_LIGHT "builtin:light"
|
||||
#define THEME_GTK3_BUILTIN_DARK "builtin:dark"
|
||||
|
||||
typedef enum
|
||||
{
|
||||
THEME_GTK3_VARIANT_FOLLOW_SYSTEM = 0,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -37,5 +37,8 @@ void theme_preferences_stage_begin (void);
|
||||
void theme_preferences_stage_apply (void);
|
||||
void theme_preferences_stage_commit (void);
|
||||
void theme_preferences_stage_discard (void);
|
||||
void theme_preferences_stage_revert_runtime (void);
|
||||
GtkWidget *theme_preferences_create_color_editor (GtkWindow *parent, gboolean *color_change_flag);
|
||||
gboolean theme_preferences_color_editor_stage (GtkWidget *editor, GError **error);
|
||||
|
||||
#endif
|
||||
@@ -367,6 +367,37 @@ theme_runtime_get_xtext_colors_mapped (const ThemeGtkPaletteMap *gtk_map, XTextC
|
||||
theme_palette_to_xtext_colors (&mapped_palette, palette, palette_len);
|
||||
}
|
||||
|
||||
gboolean
|
||||
theme_runtime_get_user_color (ThemeSemanticToken token, GdkRGBA *color)
|
||||
{
|
||||
if (token < 0 || token >= THEME_TOKEN_COUNT || !light_custom_tokens[token])
|
||||
return FALSE;
|
||||
return theme_palette_get_color (&light_palette, token, color);
|
||||
}
|
||||
|
||||
gboolean
|
||||
theme_runtime_get_default_color (ThemeSemanticToken token, const ThemeGtkPaletteMap *map, GdkRGBA *color)
|
||||
{
|
||||
ThemePalette defaults;
|
||||
gboolean custom[THEME_TOKEN_COUNT] = { FALSE };
|
||||
theme_palette_from_legacy_colors (&defaults, legacy_light_defaults, G_N_ELEMENTS (legacy_light_defaults));
|
||||
theme_runtime_apply_gtk_map (&defaults, map, custom);
|
||||
return theme_palette_get_color (&defaults, token, color);
|
||||
}
|
||||
|
||||
void
|
||||
theme_runtime_reset_user_color (ThemeSemanticToken token)
|
||||
{
|
||||
GdkRGBA color;
|
||||
if (!theme_runtime_get_default_color (token, NULL, &color))
|
||||
return;
|
||||
if (!user_colors_valid)
|
||||
light_palette = active_palette;
|
||||
theme_palette_set_color (&light_palette, token, &color);
|
||||
light_custom_tokens[token] = FALSE;
|
||||
user_colors_valid = TRUE;
|
||||
}
|
||||
|
||||
void
|
||||
theme_runtime_user_set_color (ThemeSemanticToken token, const GdkRGBA *col)
|
||||
{
|
||||
|
||||
@@ -44,6 +44,9 @@ gboolean theme_runtime_save_finalize (const char *temp_path);
|
||||
void theme_runtime_save_discard (const char *temp_path);
|
||||
gboolean theme_runtime_apply_mode (unsigned int mode, gboolean *palette_changed);
|
||||
gboolean theme_runtime_apply_dark_mode (gboolean enable);
|
||||
gboolean theme_runtime_get_user_color (ThemeSemanticToken token, GdkRGBA *color);
|
||||
void theme_runtime_reset_user_color (ThemeSemanticToken token);
|
||||
gboolean theme_runtime_get_default_color (ThemeSemanticToken token, const ThemeGtkPaletteMap *map, GdkRGBA *color);
|
||||
void theme_runtime_user_set_color (ThemeSemanticToken token, const GdkRGBA *col);
|
||||
void theme_runtime_dark_set_color (ThemeSemanticToken token, const GdkRGBA *col);
|
||||
void theme_runtime_reset_mode_colors (gboolean dark_mode);
|
||||
|
||||
@@ -187,7 +187,7 @@ populate_cb (char *urltext, gpointer userdata)
|
||||
}
|
||||
|
||||
void
|
||||
url_opengui ()
|
||||
url_opengui (void)
|
||||
{
|
||||
GtkWidget *vbox, *hbox, *view;
|
||||
char buf[128];
|
||||
|
||||
+596
-1085
File diff suppressed because it is too large.
Load diff
@@ -38,6 +38,7 @@ GType gtk_xtext_get_type (void);
|
||||
|
||||
#define ATTR_BOLD '\002'
|
||||
#define ATTR_COLOR '\003'
|
||||
#define ATTR_HEXCOLOR '\004'
|
||||
#define ATTR_BLINK '\006'
|
||||
#define ATTR_BEEP '\007'
|
||||
#define ATTR_HIDDEN '\010'
|
||||
@@ -57,6 +58,8 @@ GType gtk_xtext_get_type (void);
|
||||
#define XTEXT_BG 102
|
||||
#define XTEXT_MARKER 103 /* for marker line */
|
||||
#define XTEXT_MAX_COLOR 98
|
||||
#define XTEXT_HEX_FG 200 /* not palette slots: colors from \004RRGGBB */
|
||||
#define XTEXT_HEX_BG 201
|
||||
typedef struct textentry textentry;
|
||||
|
||||
/*
|
||||
@@ -181,6 +184,8 @@ struct _GtkXText
|
||||
|
||||
int col_fore;
|
||||
int col_back;
|
||||
XTextColor hex_fg;
|
||||
XTextColor hex_bg;
|
||||
|
||||
int depth; /* gdk window depth */
|
||||
|
||||
@@ -276,6 +281,7 @@ struct _GtkXTextClass
|
||||
|
||||
GtkWidget *gtk_xtext_new (const XTextColor *palette, int separator);
|
||||
void gtk_xtext_append (xtext_buffer *buf, unsigned char *text, int len, time_t stamp);
|
||||
void gtk_xtext_append_wrap (xtext_buffer *buf, unsigned char *text, int len, time_t stamp, int stamp_len);
|
||||
void gtk_xtext_append_indent (xtext_buffer *buf,
|
||||
unsigned char *left_text, int left_len,
|
||||
unsigned char *right_text, int right_len,
|
||||
|
||||
+10
-1
@@ -43,6 +43,9 @@
|
||||
#include "../common/util.h"
|
||||
#include "../common/fe.h"
|
||||
#include "fe-text.h"
|
||||
#ifdef __APPLE__
|
||||
#include "../common/macos.h"
|
||||
#endif
|
||||
|
||||
|
||||
static int done = FALSE;
|
||||
@@ -513,7 +516,13 @@ fe_args (int argc, char *argv[])
|
||||
g_free (locale_path);
|
||||
g_free (base_path);
|
||||
#else
|
||||
bindtextdomain (GETTEXT_PACKAGE, LOCALEDIR);
|
||||
bindtextdomain (GETTEXT_PACKAGE,
|
||||
#ifdef __APPLE__
|
||||
zoitechat_macos_locale_dir ()
|
||||
#else
|
||||
LOCALEDIR
|
||||
#endif
|
||||
);
|
||||
#endif
|
||||
bind_textdomain_codeset (GETTEXT_PACKAGE, "UTF-8");
|
||||
textdomain (GETTEXT_PACKAGE);
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user