Compare commits

..
Author SHA1 Message Date
deepend 4302a30647 rebuild macos build 2026-10-14 17:54:45 -06:00
deepend 1ae2d41378 fix menu.c build warnings 2026-10-14 05:32:13 -06:00
deepend-tildeclub 070603b0fe Merge pull request #421 from botovq/push-nsqmxvzmntnz
Fix clang -Wstrict-prototypes warnings
2026-10-13 18:16:48 -07:00
deepend-tildeclub 8f47b5cedf Merge pull request #418 from botovq/push-vzwkspvpmqvu
challengeauth_response (): move digest to stack
2026-10-13 18:13:15 -07:00
Theo Buehler 646ad029f0 Fix clang -Wstrict-prototypes warnings
This trivial diff gets rid of noisy warnings such as
```
./src/common/ignore.c:274:13: warning: a function declaration without a prototype is deprecated in all versions of C [-Wstrict-prototypes]
  274 | ignore_load ()
      |             ^
      |              void
```
when building with modern clang. This is cosmetic since these functions
are actually prototyped in a header, so if the header is in scope it's
not possible to call them with an arbitrary number of arguments.
2026-10-13 09:46:10 +02:00
Theo Buehler d47963247d challengeauth_response (): move digest to stack
Using HMAC ()'s facility of returning the digest in a static array is
not thread safe (this can't be fixed) and it is not portable to the
OpenSSL forks: BoringSSL removed support for this a decade ago (so
aws-lc doesn't support it either) and LibreSSL followed a few years
back.

There is no downside to having a 32-byte array on the stack and it would
make zoitechat safe to use from other forks without the need of patching
it.

Add an error check for the HMAC () call and handle the error in the
caller.
2026-10-13 09:25:22 +02:00
deepend 4f7add046c match theme better 2026-10-12 19:05:31 -06:00
deepend-tildeclub 61ec9af5e3 Merge pull request #420 from ZoiteChat/docs-theme-update
update theme to match new site
2026-10-12 17:11:27 -07:00
deepend 412dc92b10 update theme to match new site 2026-10-12 18:04:49 -06:00
deepend-tildeclub 04dab3dcd6 Merge pull request #412 from TehPeGaSuS/wrap-align-no-indent
Add option to align wrapped lines under the nick or message
2026-10-12 15:28:33 -07:00
ThePeGaSuS c51f885253 add option to align wrapped lines under the nick or message
When nick indentation is off, lines after the first wrap back to the
left edge, under the timestamp. Add a text_wrap_align preference
(start of line, prefix, message) so they can line up under the nick or
the message text instead. It is applied at wrap time, so existing
scrollback is re-wrapped when the setting changes, and the menu is
greyed out while nick indentation is on.
2026-10-12 17:43:09 +02:00
deepend-tildeclub aa7e20fee9 Merge pull request #417 from botovq/push-kwovwrnwwuzn
Minor simplification of _SSL_get_cert_info ()
2026-10-12 06:40:46 -07:00
deepend-tildeclub 480b99b1e7 Merge pull request #413 from B4reMetal/hex-color-parsing
feat(xtext): render \x04 hex color codes in chat text
2026-10-12 06:37:19 -07:00
BareMetal 0a4367b1da fix(xtext): move hexcolor_parse to its own file for the standalone xtext tests
master's new xtext_tests target compiles xtext.c on its own with only
GTK linked, so hexcolor_parse cannot live in util.c. Keep it in a
dependency-free hexcolor.c that both test targets compile, and register
it in the Visual Studio project for the Windows build.

Claude-Session: https://claude.ai/code/session_01Jxd4AknGzdAENADWAGHCzx
2026-10-11 23:58:09 -06:00
BareMetal 39daca9d22 feat(xtext): render \x04 hex color codes in chat text 2026-10-11 23:54:08 -06:00
deepend-tildeclub d614e25792 Merge pull request #419 from ZoiteChat/xtext-modernize
modernize rendering and fix scrollback resizing
2026-10-11 22:48:52 -07:00
deepend 30209d0c99 modernize rendering and fix scrollback resizing 2026-10-11 23:02:13 -06:00
Theo Buehler e1c44feb01 Minor simplification of _SSL_get_cert_info ()
Instead of retrieving the AlgorithmIdentifier and then reaching into it
to get the OID, the relevant OID can be obtained directly from the same
function.
2026-10-11 17:45:37 +02:00
deepend 5af8f4ede1 correct echo-message capability negotiation 2026-10-03 19:52:16 -06:00
85 changed files with 4820 additions and 4543 deletions
+154
View File
@@ -0,0 +1,154 @@
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@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- 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
- 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@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
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
+6
View File
@@ -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
View File
@@ -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.
+694 -2281
View File
File diff suppressed because it is too large Load Diff
+526
View File
@@ -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; }
+44
View File
@@ -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();
}
})();
+5
View File
@@ -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>
+108
View File
@@ -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 %}
+7
View File
@@ -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 %}
+10
View File
@@ -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 %}
+7
View File
@@ -0,0 +1,7 @@
[theme]
inherit = basic
stylesheet = basic.css
pygments_style = sphinx
sidebars = searchbox.html, globaltoc.html, localtoc.html, sourcelink.html
[options]
+8 -4
View File
@@ -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,13 +127,16 @@ 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
@@ -145,8 +148,9 @@ html_favicon = '_static/favicon.ico'
# 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.
-30
View File
@@ -1,30 +0,0 @@
# Optional Python asyncio integration
`run_async(coroutine, context=None, interval=10)` returns an AsyncTask with
`done()`, `cancel()` and `result()`. Each invocation owns a separate event loop
pumped by an existing main-loop timer. A ready stop callback ensures selector
polling never blocks the UI. The captured context is restored after every pump;
a closed context cancels the work. No process-wide event-loop policy is changed.
Existing synchronous scripts are unaffected. Use `asyncio.get_running_loop()`
inside the coroutine. Child tasks use the same loop/context and are cancelled
when the main coroutine finishes or the script unloads. Cancellation cleanup is
bounded; tasks must not suppress cancellation indefinitely. Cleanup uses the
captured context while it remains open; after it closes, cancellation cleanup uses
the current valid context. Use explicit Context methods for context-specific
output in finally blocks, and do not issue context-dependent commands there. This provides normal
asyncio sockets/timers, not Windows subprocess support or a new HTTP library.
Asyncio hostname resolution can use its executor; use a resolver appropriate to
your library if you require no worker threads. Blocking work inside a coroutine
still blocks the client and must be avoided.
```python
import asyncio
import zoitechat
async def later():
await asyncio.sleep(1)
zoitechat.prnt('Still in the context where this task was started')
task = zoitechat.run_async(later())
```
-35
View File
@@ -1,35 +0,0 @@
# Script-supported capabilities
`register_capability(name, userdata=None, connection_id=None)` returns a normal
hook handle. Register before connecting/reconnecting. Core requests the name only
if advertised in CAP LS or CAP NEW. Existing built-in requests remain enabled;
SASL and STS registration is rejected so scripts cannot change authentication or
transport policy through this API. None/-1 means any connection (zero is a valid
connection ID). Names are lowercase ASCII alphanumerics plus - . / _, 1-200 bytes.
Duplicate registrations do not duplicate requests for an advertised token.
`unhook(handle)` or script/plugin unload removes future request ownership. It
intentionally does not disable a capability already acknowledged on a live
connection, because another plugin/core may depend on it. Registering after CAP
LS does not retrospectively negotiate an already-advertised capability: reconnect
or wait for CAP NEW. Core retains ownership of CAP END; this API does not extend
SASL waiting or delay registration awaiting plugin-only CAP ACKs.
`hook_capability(name, callback, userdata=None, priority=PRI_NORM)` independently
observes negotiation through existing raw CAP hooks. Callback receives
`(CapabilityEvent, userdata)` with name, subcommand, value, enabled, context and
connection_id. LS/NEW mean advertised (`enabled=None`), ACK/LIST mean enabled
(unless a negative ACK), NAK means rejected and DEL means removed. Callback
returns are ignored so this observer cannot eat core negotiation. Existing raw
hooks may still eat CAP before this hook according to their normal priorities.
C: `zoitechat_register_capability(ph, name, flags, connection_id, userdata)`;
flags=0, ID=-1 for any. Check get_info("api_plugin_caps") for "1" before using
the appended Windows slot. Use existing server CAP hooks for notifications.
Requests are split at complete token boundaries within the existing wire limit.
```python
registration = zoitechat.register_capability('draft/example')
zoitechat.hook_capability('draft/example', lambda event, data:
event.context.prnt('{}: {}'.format(event.subcommand, event.enabled)))
```
@@ -1,24 +0,0 @@
# Optional plugin API additions (discussion #144)
Existing hooks, callbacks, return values, context lookup, strings and menu behaviour
remain unchanged. The C plugin function table gains four fixed append-only slots;
unused slots are NULL. Older binaries use the unchanged prefix. A plugin must check
the documented get_info() feature key before accessing a new slot on an older host.
New Python modules are installed by Meson and the existing Windows copy/installer
pipeline. Only modules present in the installation are exported; imports of
zoitechat, hexchat and xchat retain their compatibility aliases.
All APIs run on the client main thread unless explicitly documented otherwise.
New reserved flags must be zero. These changes do not make old API calls thread-safe.
Nested hook dispatch defers deleted-hook reclamation until its outermost return,
so new observers can safely emit events and unhook themselves or other hooks.
The optional asyncio module requires Python 3.7 or newer; older supported
interpreters skip that module and retain the existing scripting API.
Python reload drops the script/API modules while retaining the embedding module
so callback and lib.ph pointer types keep the same CFFI identity.
Server-hook insertion matches both raw and attrs hooks so documented priorities
are honoured for mixed registrations, including new filtered attrs callbacks.
-24
View File
@@ -1,24 +0,0 @@
# Connection and context navigation
`get_connections()` returns one Connection snapshot per live connection:
`id`, `network`, `server`, `context`. Connection IDs distinguish simultaneous
connections with identical names. Prefer its server-tab context where present.
`get_connection(context=None)` finds the owner of a live context.
`get_contexts(connection_id=None, types=None)` lists live contexts, optionally
filtered by connection ID (or a Connection object) and session types: server=1,
channel=2, query=3. `connection.contexts(types=None)` is a convenience method.
`find_context_by_id(connection_id, channel=None)` / `connection.find(channel)`
resolve using that connection's IRC comparator; no channel selects the server tab.
Missing/closed contexts return None. Existing find_context() remains unchanged.
These helpers use the existing channels list's ID/context fields, so no new C
context ABI is needed. Snapshots are not persistent IDs across client restarts;
refresh them after closing/recreating connections. Always check Context.set()
when explicitly selecting a context that might have closed.
```python
connection = zoitechat.get_connection()
for context in connection.contexts(types=(2,)):
context.prnt('A channel on this exact connection')
```
-26
View File
@@ -1,26 +0,0 @@
# Semantic IRC hooks
`on_join`, `on_part`, `on_quit`, `on_nick`, `on_kick`, `on_message`, and
`on_notice` accept `(callback, userdata=None, priority=PRI_NORM)`. The general
form is `hook_event(command, callback, userdata=None, priority=PRI_NORM)`.
Callbacks receive `(event, userdata)`, where the immutable IRCEvent has
command, prefix, nick, user, host, params, context, connection_id and time.
`params` preserves the trailing parameter, including spaces; JOIN parameters
also retain extended-join account/realname data when supplied by the server.
These wrappers use existing server hooks, before core command processing;
return the ordinary EAT_* constants. EAT_ZOITECHAT suppresses the entire IRC
command (including client state updates), not just its text. Use EAT_NONE for
observation. Other plugins' priority and eating rules remain unchanged.
The context is the core-selected context, which can be the server tab when a
query/channel does not yet exist. QUIT is one event per incoming command,
not one callback per affected channel. `event.time` is the existing server-time
attribute (zero when unavailable); other message tags are not exposed here.
```python
def joined(event, userdata):
event.context.prnt('{} joined {}'.format(event.nick, event.params[0]))
return zoitechat.EAT_NONE
zoitechat.on_join(joined)
```
-19
View File
@@ -1,19 +0,0 @@
# Python descriptor hooks
`hook_fd(fd, callback, userdata=None, flags=FD_READ)` returns an ordinary hook
handle accepted by `unhook()`. Callback arguments are `(fd, ready_flags, userdata)`;
return True to retain the watch and False/None to remove it. Exceptions print a
traceback and remove the watch. Unloading the script removes its watches.
The callback runs in the context captured during registration, and that context
is restored afterwards. A closed context removes the watch.
Use nonblocking IO and bounded reads/writes. The descriptor belongs to the script;
unhook before closing it. On Windows socket descriptors use the default flags;
CRT descriptors need FD_NOTSOCKET. The existing C API uses int descriptors: values
outside its range are rejected rather than truncated. This does not add threads.
Example:
```python
watch = zoitechat.hook_fd(sock.fileno(), on_readable, flags=zoitechat.FD_READ)
# def on_readable(fd, flags, userdata): ...; return True
```
-24
View File
@@ -1,24 +0,0 @@
# Core-filtered hooks
Python: `hook_server_filtered(name, callback, userdata=None, priority=PRI_NORM,
connection_id=None, channel=None)` and `hook_print_filtered(...)`.
Callbacks match the existing attrs variants: `(word, word_eol, userdata, attrs)`.
The connection ID distinguishes duplicate names; None/-1 means any connection
(including connection ID zero). A channel restriction uses that connection's
IRC comparator. None means any context; an empty string is an exact restriction.
C: the corresponding zoitechat_hook_*_filtered functions accept
`(ph, name, pri, flags, connection_id, channel, callback, userdata)`;
flags=0, ID=-1 for any and NULL channel for any. Check
`get_info("api_context_filters")` for "1" before accessing the new Windows slots.
Filtering takes place before Python/interpreter entry. Existing unrestricted
hooks, priority, EAT semantics and unload/unhook handling are unchanged.
Filters refer to the core-selected context, not an IRC target parsed from the
line: unknown queries/channels can still have a server-tab context, and QUIT
has its usual single server-hook context. Connection IDs last only this process.
```python
zoitechat.hook_print_filtered('Channel Message', callback,
connection_id=zoitechat.get_prefs('id'), channel='#zoitechat')
```
-21
View File
@@ -1,21 +0,0 @@
# Explicit IRC comparison keys
`IRCName(text, context=None, casemapping=None, connection_id=None)` is an
immutable helper, not a str subclass. Construct it explicitly for dictionary/set
keys; existing get_info/list/event values remain normal strings.
Equality and hashing include the connection ID and captured casemapping, so
identical-looking names on different connections are different keys. An IRCName
never compares equal to a plain str. `str(key)` / `key.text` recover the spelling.
`irc_casefold(text, casemapping='rfc1459')` supports ascii, strict-rfc1459 and
rfc1459. Only ASCII IRC characters fold; Unicode casefold would be incorrect.
The new `get_info('casemapping')` reports the comparator actually used by core.
Current master handles ASCII and RFC1459; this patch does not change its handling
of advertised strict-rfc1459. An explicit mapping is available for scripts that
need strict-rfc1459 independently. Keys capture their mapping so hashes stay
stable; rebuild a collection if the connection's mapping changes.
```python
seen = {zoitechat.IRCName('[SomeNick]')}
assert zoitechat.IRCName('{somenick}') in seen # RFC1459 connection
```
-21
View File
@@ -1,21 +0,0 @@
# Post-display print observers
Python: `hook_print_after(name, callback, userdata=None, priority=PRI_NORM)`.
The callback is `(word, word_eol, userdata, attrs)`, matching hook_print_attrs().
It runs after display_event() has handled the text, so prnt() follows the original
text. Return values are ignored. Events eaten by a pre-hook or hidden by client
preferences do not invoke these observers. This is post-display notification,
not a notification that the entire incoming IRC command has finished updating state.
C: `zoitechat_hook_print_after(ph, name, priority, flags, callback, userdata)`;
flags must be zero. Check `get_info("api_hook_print_after")` for "1" before
accessing the appended Windows function-table slot. Existing pre-hook timing is
unchanged. Unhook/unload use existing lifecycle functions. Recursive emission
skips an already-running observer to prevent it immediately invoking itself.
```python
def after(word, word_eol, userdata, attrs):
zoitechat.prnt('This follows the original channel message')
zoitechat.hook_print_after('Channel Message', after)
```
+19 -1
View File
@@ -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')
+4
View File
@@ -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'
+15
View File
@@ -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"
+335
View File
@@ -0,0 +1,335 @@
# 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
```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.
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
+71
View File
@@ -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}")
+358
View File
@@ -0,0 +1,358 @@
#!/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
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 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")}
# 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.
(resources / "fonts.conf").write_text('''<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<dir>/System/Library/Fonts</dir><dir>/Library/Fonts</dir><dir>~/Library/Fonts</dir>
<cachedir prefix="xdg">fontconfig</cachedir>
<alias><family>sans-serif</family><prefer><family>Helvetica</family></prefer></alias>
<alias><family>monospace</family><prefer><family>Menlo</family></prefer></alias>
</fontconfig>
''')
(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}")
+6
View File
@@ -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>
+220
View File
@@ -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")
+49
View File
@@ -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>
+125
View File
@@ -0,0 +1,125 @@
"""Dependency parsing, relocation guards and release audit tests without a Mac SDK."""
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import bundle
class BundleTests(unittest.TestCase):
@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()
+153
View File
@@ -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()
-21
View File
@@ -383,24 +383,3 @@ def find_context(server=None, channel=None):
return None
return Context(ctx)
# Optional additive APIs live in separate modules so builds can select features.
def __load_additive_features():
import importlib
import os
for suffix in ('fd', 'events', 'async', 'names', 'contexts',
'post', 'filters', 'caps'):
if suffix == 'async' and sys.version_info < (3, 7):
continue
name = '_zoitechat_' + suffix
if not os.path.isfile(os.path.join(os.path.dirname(__file__), name + '.py')):
continue
module = importlib.import_module(name)
for symbol in module.__all__:
globals()[symbol] = getattr(module, symbol)
__all__.append(symbol)
__load_additive_features()
-100
View File
@@ -1,100 +0,0 @@
"""Opt-in asyncio tasks pumped without blocking the client main loop."""
import asyncio
import operator
import traceback
import _zoitechat as api
__all__ = ['AsyncTask', 'run_async']
class AsyncTask:
"""A script-owned task and isolated event loop; cancel() is cooperative."""
def __init__(self, coroutine, plugin, context, interval):
self._plugin = plugin
self._context = context
self._loop = asyncio.new_event_loop()
self._task = self._loop.create_task(coroutine)
self._closed = False
self._unload_hook = api.hook_unload(lambda data: self._close())
self._timer_hook = api.hook_timer(interval, self._tick)
def done(self):
return self._task.done()
def cancel(self):
if not self._closed:
return self._task.cancel()
return False
def result(self):
return self._task.result()
def _pump(self):
# A ready stop callback forces the selector's timeout to zero.
self._loop.call_soon(self._loop.stop)
self._loop.run_forever()
def _close(self):
if self._closed:
return
self._closed = True
previous = api.get_context()
self._context.set()
try:
pending = asyncio.all_tasks(self._loop)
for task in pending:
task.cancel()
# Bound cleanup work; tasks must cooperate with cancellation.
for unused in range(3):
if not any(not task.done() for task in pending):
break
self._pump()
for task in pending:
if not task.done():
self._loop.call_exception_handler({
'message': 'Script task ignored cancellation during unload',
'task': task,
})
finally:
self._loop.close()
previous.set()
def _tick(self, userdata):
if self._closed:
return False
previous = api.get_context()
if not self._context.set():
self._close()
self._plugin.remove_hook(self._unload_hook)
return False
try:
self._pump()
if not self._task.done():
return True
try:
self._task.result()
except asyncio.CancelledError:
pass
except Exception:
traceback.print_exc()
self._close()
self._plugin.remove_hook(self._unload_hook)
return False
except Exception:
traceback.print_exc()
self._close()
self._plugin.remove_hook(self._unload_hook)
return False
finally:
previous.set()
def run_async(coroutine, context=None, interval=10):
"""Schedule a coroutine in a captured context; return an AsyncTask handle."""
interval = operator.index(interval)
if interval < 1 or interval > 2147483647:
raise ValueError('interval must be a positive C int in milliseconds')
if not asyncio.iscoroutine(coroutine):
raise TypeError('run_async expects a coroutine object')
plugin = api.__get_current_plugin()
return AsyncTask(coroutine, plugin, context or api.get_context(), interval)
-64
View File
@@ -1,64 +0,0 @@
"""Script-owned capability requests and structured negotiation observations."""
from collections import namedtuple
import operator
import _zoitechat as api
from _zoitechat_embedded import ffi, lib
__all__ = ['CapabilityEvent', 'register_capability', 'hook_capability']
CapabilityEvent = namedtuple('CapabilityEvent',
'name subcommand value enabled context connection_id')
def register_capability(name, userdata=None, connection_id=None):
"""Request an advertised capability on next negotiation; unhook releases ownership."""
connection_id = -1 if connection_id is None else operator.index(connection_id)
if connection_id < -1 or connection_id > 2147483647:
raise ValueError('connection_id must fit the C API; -1 means any')
plugin = api.__get_current_plugin()
hook = plugin.add_hook(None, userdata)
handle = lib.zoitechat_register_capability(lib.ph, name.encode(), 0,
connection_id, hook.handle)
if handle == ffi.NULL:
hook.is_unload = True
plugin.remove_hook(id(hook))
raise ValueError('invalid capability name, reserved capability or connection ID')
hook.zoitechat_hook = handle
return id(hook)
def hook_capability(name, callback, userdata=None, priority=api.PRI_NORM):
"""Observe LS/NEW/ACK/NAK/DEL/LIST; this hook does not itself request the capability."""
if not callable(callback):
raise TypeError('callback must be callable')
def dispatch(word, word_eol, data):
if not word_eol:
return api.EAT_NONE
line = word_eol[0]
if line.startswith(':'):
unused, separator, line = line.partition(' ')
parts = line.split(' ', 3)
if len(parts) != 4 or parts[0].upper() != 'CAP':
return api.EAT_NONE
subcommand, tokens = parts[2].upper(), parts[3]
if subcommand not in ('LS', 'NEW', 'ACK', 'NAK', 'DEL', 'LIST'):
return api.EAT_NONE
if tokens.startswith('* '):
tokens = tokens[2:]
tokens = tokens.lstrip(':')
for token in tokens.split():
negative = token.startswith('-')
cap, equals, value = token.lstrip('-').partition('=')
if cap != name:
continue
enabled = None
if subcommand in ('ACK', 'LIST'):
enabled = not negative
elif subcommand in ('DEL', 'NAK'):
enabled = False
event = CapabilityEvent(cap, subcommand, value if equals else None,
enabled, api.get_context(), api.get_prefs('id'))
callback(event, data)
return api.EAT_NONE # observing CAP must not suppress core negotiation
return api.hook_server('CAP', dispatch, userdata, priority)
-75
View File
@@ -1,75 +0,0 @@
"""Navigate live connections by ID instead of ambiguous server/network names."""
from collections import namedtuple
import operator
import _zoitechat as api
__all__ = ['Connection', 'get_connections', 'get_connection', 'get_contexts',
'find_context_by_id']
class Connection(namedtuple('_Connection', 'id network server context')):
__slots__ = ()
def contexts(self, types=None):
return get_contexts(self.id, types)
def find(self, channel=None):
return find_context_by_id(self.id, channel)
def _id(connection):
return operator.index(connection.id if isinstance(connection, Connection) else connection)
def get_connections():
"""Return one snapshot per live connection, including disconnected ones."""
entries = api.get_list('channels')
by_id = {}
order = []
for entry in entries:
if entry.id not in by_id:
order.append(entry.id)
if entry.id not in by_id or entry.type == 1:
by_id[entry.id] = Connection(entry.id, entry.network, entry.server,
entry.context)
return [by_id[connection_id] for connection_id in order]
def get_connection(context=None):
"""Find the owning connection of a Context; return None when it has closed."""
context = context or api.get_context()
for entry in api.get_list('channels'):
if entry.context == context:
for connection in get_connections():
if connection.id == entry.id:
return connection
return None
def get_contexts(connection_id=None, types=None):
"""Return contexts, optionally restricted to a connection and session types."""
if connection_id is not None:
connection_id = _id(connection_id)
if types is not None:
types = frozenset(types)
return [entry.context for entry in api.get_list('channels')
if (connection_id is None or entry.id == connection_id)
and (types is None or entry.type in types)]
def find_context_by_id(connection_id, channel=None):
"""Resolve a live context on exactly one connection; None selects its server tab."""
connection_id = _id(connection_id)
previous = api.get_context()
try:
for entry in api.get_list('channels'):
if entry.id != connection_id:
continue
if channel is None:
if entry.type == 1:
return entry.context
elif entry.context.set() and api.nickcmp(entry.channel, channel) == 0:
return entry.context
finally:
previous.set()
return None
-77
View File
@@ -1,77 +0,0 @@
"""Structured, opt-in hooks before the IRC command's core processing."""
from collections import namedtuple
import _zoitechat as api
__all__ = ['IRCEvent', 'hook_event', 'on_join', 'on_part', 'on_quit',
'on_nick', 'on_kick', 'on_message', 'on_notice']
IRCEvent = namedtuple('IRCEvent', 'command prefix nick user host params context connection_id time')
_SUPPORTED = frozenset(('JOIN', 'PART', 'QUIT', 'NICK', 'KICK', 'PRIVMSG', 'NOTICE'))
def _parse(line):
prefix = ''
if line.startswith(':'):
prefix, separator, line = line[1:].partition(' ')
if not separator:
return None
command, separator, rest = line.partition(' ')
params = []
while rest:
rest = rest.lstrip(' ')
if rest.startswith(':'):
params.append(rest[1:])
break
param, separator, rest = rest.partition(' ')
if param:
params.append(param)
nick, marker, userhost = prefix.partition('!')
user, marker, host = userhost.partition('@')
return command.upper(), prefix, nick, user, host, tuple(params)
def hook_event(command, callback, userdata=None, priority=api.PRI_NORM):
"""callback(event, userdata) returns existing EAT_* constants."""
command = command.upper()
if command not in _SUPPORTED:
raise ValueError('unsupported semantic event: ' + command)
if not callable(callback):
raise TypeError('callback must be callable')
def dispatch(word, word_eol, data, attrs):
if not word_eol:
return api.EAT_NONE
parsed = _parse(word_eol[0])
if parsed is None or parsed[0] != command:
return api.EAT_NONE
event = IRCEvent(*(parsed + (api.get_context(), api.get_prefs('id'), attrs.time)))
return callback(event, data)
return api.hook_server_attrs(command, dispatch, userdata, priority)
def on_join(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('JOIN', callback, userdata, priority)
def on_part(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('PART', callback, userdata, priority)
def on_quit(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('QUIT', callback, userdata, priority)
def on_nick(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('NICK', callback, userdata, priority)
def on_kick(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('KICK', callback, userdata, priority)
def on_message(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('PRIVMSG', callback, userdata, priority)
def on_notice(callback, userdata=None, priority=api.PRI_NORM):
return hook_event('NOTICE', callback, userdata, priority)
-39
View File
@@ -1,39 +0,0 @@
"""File descriptor readiness hooks; callbacks execute on the main thread."""
import operator
import _zoitechat as api
from _zoitechat_embedded import ffi, lib
__all__ = ['FD_READ', 'FD_WRITE', 'FD_EXCEPTION', 'FD_NOTSOCKET', 'hook_fd']
FD_READ, FD_WRITE, FD_EXCEPTION, FD_NOTSOCKET = 1, 2, 4, 8
def hook_fd(fd, callback, userdata=None, flags=FD_READ):
"""callback(fd, ready_flags, userdata): True keeps the watch, False removes it."""
fd = operator.index(fd)
flags = operator.index(flags)
if fd < 0 or fd > 2147483647:
raise ValueError('fd must fit the C API nonnegative int descriptor')
if flags & ~15 or not flags & 7:
raise ValueError('flags must select READ, WRITE or EXCEPTION')
if not callable(callback):
raise TypeError('callback must be callable')
plugin = api.__get_current_plugin()
context = api.get_context()
def dispatch(descriptor, ready, data):
previous = api.get_context()
if not context.set():
return False
try:
return callback(descriptor, ready, data)
finally:
previous.set()
hook = plugin.add_hook(dispatch, userdata)
handle = lib.zoitechat_hook_fd(lib.ph, fd, flags, lib._on_fd_hook, hook.handle)
if handle == ffi.NULL:
hook.is_unload = True
plugin.remove_hook(id(hook))
raise RuntimeError('unable to watch descriptor')
hook.zoitechat_hook = handle
return id(hook)
-36
View File
@@ -1,36 +0,0 @@
"""Context restrictions evaluated by core before entering Python."""
import operator
import _zoitechat as api
from _zoitechat_embedded import ffi, lib
__all__ = ['hook_server_filtered', 'hook_print_filtered']
def _register(name, callback, userdata, priority, connection_id, channel, server):
if not callable(callback):
raise TypeError('callback must be callable')
connection_id = -1 if connection_id is None else operator.index(connection_id)
if connection_id < -1 or connection_id > 2147483647:
raise ValueError('connection_id must fit the C API; -1 means any')
plugin = api.__get_current_plugin()
hook = plugin.add_hook(callback, userdata)
register = lib.zoitechat_hook_server_filtered if server else lib.zoitechat_hook_print_filtered
dispatch = lib._on_server_attrs_hook if server else lib._on_print_attrs_hook
handle = register(lib.ph, name.encode(), priority, 0, connection_id,
ffi.NULL if channel is None else channel.encode(), dispatch, hook.handle)
if handle == ffi.NULL:
hook.is_unload = True
plugin.remove_hook(id(hook))
raise RuntimeError('unable to install filtered hook')
hook.zoitechat_hook = handle
return id(hook)
def hook_server_filtered(name, callback, userdata=None, priority=api.PRI_NORM,
connection_id=None, channel=None):
return _register(name, callback, userdata, priority, connection_id, channel, True)
def hook_print_filtered(name, callback, userdata=None, priority=api.PRI_NORM,
connection_id=None, channel=None):
return _register(name, callback, userdata, priority, connection_id, channel, False)
-74
View File
@@ -1,74 +0,0 @@
"""Explicit immutable IRC names; existing string-returning APIs are unchanged."""
import _zoitechat as api
__all__ = ['IRCName', 'irc_casefold']
def irc_casefold(text, casemapping='rfc1459'):
"""Fold ASCII IRC characters only; never apply Unicode lower()/casefold()."""
if casemapping not in ('ascii', 'strict-rfc1459', 'rfc1459'):
raise ValueError('unsupported IRC casemapping')
extra = {'[': '{', ']': '}', '\\': '|'} if casemapping != 'ascii' else {}
if casemapping == 'rfc1459':
extra['^'] = '~'
return ''.join(chr(ord(c) + 32) if 'A' <= c <= 'Z' else extra.get(c, c)
for c in text)
class IRCName:
"""Equality/hash include connection identity and the captured casemapping."""
__slots__ = ('_text', '_casemapping', '_connection_id', '_key')
def __init__(self, text, context=None, casemapping=None, connection_id=None):
context = context or api.get_context()
if casemapping is None:
casemapping = context.get_info('casemapping')
if casemapping is None:
raise RuntimeError('host does not expose its comparison casemapping')
if connection_id is None:
previous = api.get_context()
if not context.set():
raise ValueError('context has closed')
try:
connection_id = api.get_prefs('id')
finally:
previous.set()
object.__setattr__(self, '_text', text)
object.__setattr__(self, '_casemapping', casemapping)
object.__setattr__(self, '_connection_id', connection_id)
object.__setattr__(self, '_key', (connection_id, casemapping,
irc_casefold(text, casemapping)))
def __setattr__(self, name, value):
raise AttributeError('IRCName is immutable')
@property
def text(self):
return self._text
@property
def casemapping(self):
return self._casemapping
@property
def connection_id(self):
return self._connection_id
def __str__(self):
return self._text
def __repr__(self):
return 'IRCName({!r}, casemapping={!r}, connection_id={!r})'.format(
self._text, self._casemapping, self._connection_id)
def __eq__(self, other):
if not isinstance(other, IRCName):
return NotImplemented
return self._key == other._key
def __ne__(self, other):
result = self.__eq__(other)
return NotImplemented if result is NotImplemented else not result
def __hash__(self):
return hash(self._key)
-21
View File
@@ -1,21 +0,0 @@
"""Opt-in post-display print observers."""
import _zoitechat as api
from _zoitechat_embedded import ffi, lib
__all__ = ['hook_print_after']
def hook_print_after(name, callback, userdata=None, priority=api.PRI_NORM):
"""callback(word, word_eol, userdata, attrs); its return value is ignored."""
if not callable(callback):
raise TypeError('callback must be callable')
plugin = api.__get_current_plugin()
hook = plugin.add_hook(callback, userdata)
handle = lib.zoitechat_hook_print_after(lib.ph, name.encode(), priority, 0,
lib._on_print_attrs_hook, hook.handle)
if handle == ffi.NULL:
hook.is_unload = True
plugin.remove_hook(id(hook))
raise RuntimeError('unable to install post-display hook')
hook.zoitechat_hook = handle
return id(hook)
-1
View File
@@ -38,7 +38,6 @@ extern "Python" int _on_print_attrs_hook(char **, zoitechat_event_attrs *, void
extern "Python" int _on_server_hook(char **, char **, void *);
extern "Python" int _on_server_attrs_hook(char **, char **, zoitechat_event_attrs *, void *);
extern "Python" int _on_timer_hook(void *);
extern "Python" int _on_fd_hook(int, int, void *);
extern "Python" int _on_plugin_init(char **, char **, char **, char *, char *);
extern "Python" int _on_plugin_deinit(void);
+1 -5
View File
@@ -27,11 +27,7 @@ python3_source = custom_target('python-bindings',
command: [find_program('generate_plugin.py'), '@INPUT@', '@OUTPUT@']
)
# Include any selected additive API modules alongside the compatibility modules.
python_api_files = run_command(python3, '-c',
'import glob,sys; print("\\n".join(sorted(glob.glob(sys.argv[1] + "/_zoitechat*.py"))))',
meson.current_source_dir(), check: true).stdout().strip().split('\n')
install_data(python_api_files + ['zoitechat.py', 'xchat.py', 'hexchat.py'],
install_data(['_zoitechat.py', 'zoitechat.py', 'xchat.py', 'hexchat.py'],
install_dir: join_paths(get_option('libdir'), 'zoitechat/python')
)
+1 -28
View File
@@ -304,28 +304,6 @@ def _on_timer_hook(userdata):
return 0
@ffi.def_extern()
def _on_fd_hook(fd, flags, userdata):
hook = ffi.from_handle(userdata)
keep = False
try:
keep = bool(hook.callback(fd, flags, hook.userdata))
except Exception:
traceback.print_exc()
if not keep:
# The C watch removes itself on return; do not unhook it a second time.
try:
hook.is_unload = True
# from_handle() yields a weak proxy: id(proxy) is not id(Hook).
for existing in hook.plugin.hooks:
if existing == hook:
hook.plugin.remove_hook(id(existing))
break
except ReferenceError:
pass # the callback already unhooked itself
return int(keep)
@ffi.def_extern()
def _on_say_command(word, word_eol, userdata):
"""Handle input in the special >>python<< tab.
@@ -612,12 +590,7 @@ def _on_plugin_deinit():
sys.stderr = sys.__stderr__
pydoc.help = pydoc.Helper()
# CFFI callbacks keep the embedding module's ffi/lib identity for the
# process lifetime; deleting it creates incompatible types on reload.
for mod in ('_zoitechat', 'zoitechat', 'hexchat', 'xchat',
'_zoitechat_fd', '_zoitechat_events', '_zoitechat_async',
'_zoitechat_names', '_zoitechat_contexts', '_zoitechat_post',
'_zoitechat_filters', '_zoitechat_caps'):
for mod in ('_zoitechat', 'zoitechat', 'xchat', '_zoitechat_embedded'):
try:
del sys.modules[mod]
-1
View File
@@ -52,7 +52,6 @@
<None Include="xchat.py" />
<None Include="hexchat.py" />
<None Include="_zoitechat.py" />
<None Include="_zoitechat_*.py" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="$(IntDir)python.c" />
-1
View File
@@ -52,7 +52,6 @@
<None Include="xchat.py" />
<None Include="hexchat.py" />
<None Include="_zoitechat.py" />
<None Include="_zoitechat_*.py" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="$(IntDir)python.c" />
+2
View File
@@ -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 */
+2
View File
@@ -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" />
+6
View File
@@ -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>
+42
View File
@@ -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;
}
+16
View File
@@ -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
View File
@@ -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 -104
View File
@@ -41,8 +41,6 @@
#include "network.h"
#include "notify.h"
#include "outbound.h"
#include <gmodule.h>
#include "plugin.h"
#include "inbound.h"
#include "server.h"
#include "servlist.h"
@@ -1882,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)
@@ -1964,58 +1975,15 @@ inbound_cap_ack (server *serv, char *nick, char *extensions,
inbound_toggle_caps (serv, extensions, TRUE);
}
/* Split requests only at token boundaries, including plugin-added names. */
static void
inbound_cap_request_append (server *serv, char *buffer, gsize size,
const char *name, time_t timestamp)
{
if (strlen (buffer) + strlen (name) + 1 >= size)
{
EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPREQ, serv->server_session,
buffer + 9, NULL, NULL, NULL, 0, timestamp);
tcp_sendf (serv, "%s\r\n", g_strchomp (buffer));
g_strlcpy (buffer, "CAP REQ :", size);
}
g_strlcat (buffer, name, size);
g_strlcat (buffer, " ", size);
}
static void
inbound_plugin_cap_new (server *serv, const char *extensions, time_t timestamp)
{
char buffer[500] = "CAP REQ :";
char **tokens = g_strsplit (extensions, " ", 0);
GHashTable *seen = g_hash_table_new (g_str_hash, g_str_equal);
int i;
gboolean requested = FALSE;
for (i = 0; tokens[i]; i++)
{
char *value = strchr (tokens[i], '=');
if (value)
*value = '\0';
if (plugin_requests_capability (serv, tokens[i]) &&
!g_hash_table_contains (seen, tokens[i]))
{
g_hash_table_add (seen, tokens[i]);
inbound_cap_request_append (serv, buffer, sizeof (buffer), tokens[i], timestamp);
requested = TRUE;
}
}
if (requested)
{
EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPREQ, serv->server_session,
buffer + 9, NULL, NULL, NULL, 0, timestamp);
tcp_sendf (serv, "%s\r\n", g_strchomp (buffer));
}
g_hash_table_destroy (seen);
g_strfreev (tokens);
}
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);
@@ -2036,41 +2004,22 @@ inbound_cap_new (server *serv, char *nick, char *extensions,
g_strfreev (tokens);
}
if (extensions && !sts_upgrade_triggered)
inbound_plugin_cap_new (serv, extensions, tags_data->timestamp);
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);
}
@@ -2106,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)
{
@@ -2169,7 +2156,6 @@ inbound_cap_ls (server *serv, char *nick, char *extensions_str,
gboolean want_cap = FALSE; /* format the CAP REQ string based on previous capabilities being requested or not */
gboolean sts_upgrade_triggered = FALSE;
char **extensions;
GString *requests = g_string_new ("");
int i;
if (g_str_has_prefix (extensions_str, "* "))
@@ -2235,7 +2221,7 @@ inbound_cap_ls (server *serv, char *nick, char *extensions_str,
}
want_cap = TRUE;
serv->waiting_on_sasl = TRUE;
g_string_append (requests, "sasl ");
g_strlcat (buffer, "sasl ", sizeof(buffer));
continue;
}
@@ -2243,44 +2229,28 @@ inbound_cap_ls (server *serv, char *nick, char *extensions_str,
{
if (!g_strcmp0 (extension, supported_caps[x]))
{
g_string_append (requests, extension);
g_string_append_c (requests, ' ');
g_strlcat (buffer, extension, sizeof(buffer));
g_strlcat (buffer, " ", sizeof(buffer));
want_cap = TRUE;
break;
}
}
if (x == G_N_ELEMENTS (supported_caps) && plugin_requests_capability (serv, extension))
{
g_string_append (requests, extension);
g_string_append_c (requests, ' ');
want_cap = TRUE;
}
}
g_strfreev (extensions);
if (sts_upgrade_triggered)
{
g_string_free (requests, TRUE);
return;
}
if (want_cap)
{
char **tokens = g_strsplit (requests->str, " ", 0);
/* Defer every batch until STS has decided whether to reconnect. */
for (i = 0; tokens[i]; i++)
if (tokens[i][0])
inbound_cap_request_append (serv, buffer, sizeof (buffer), tokens[i], tags_data->timestamp);
g_strfreev (tokens);
/* buffer + 9 = emit buffer without "CAP REQ :" */
EMIT_SIGNAL_TIMESTAMP (XP_TE_CAPREQ, serv->server_session,
buffer + 9, NULL, NULL, NULL, 0,
tags_data->timestamp);
tcp_sendf (serv, "%s\r\n", g_strchomp (buffer));
}
g_string_free (requests, TRUE);
if (!serv->waiting_on_sasl && !serv->waiting_on_cap)
{
/* if we use SASL, CAP END is dealt via raw numerics */
+127
View File
@@ -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;
}
+6
View File
@@ -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
View File
@@ -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
View File
@@ -662,7 +662,7 @@ notify_isnotify (struct session *sess, char *name)
}
void
notify_cleanup ()
notify_cleanup (void)
{
GSList *list = notify_list;
GSList *nslist, *srvlist;
+2 -149
View File
@@ -99,10 +99,6 @@ struct _zoitechat_hook
int tag; /* for timers & FDs only */
int type; /* HOOK_* */
int pri; /* fd */ /* priority / fd for HOOK_FD only */
int cap_connection_id;
int connection_filter;
char *channel_filter;
gboolean context_filtered;
};
struct _zoitechat_list
@@ -147,8 +143,6 @@ enum
HOOK_PRINT_ATTRS = 1 << 4, /* same as above, with attributes */
HOOK_TIMER = 1 << 5, /* timeouts */
HOOK_FD = 1 << 6, /* sockets & fds */
HOOK_PRINT_AFTER = 1 << 8, /* opt-in post-display observers */
HOOK_CAPABILITY = 1 << 9, /* script-owned CAP requests */
HOOK_DELETED = 1 << 7 /* marked for deletion */
};
@@ -182,7 +176,6 @@ enum
GSList *plugin_list = NULL; /* export for plugingui.c */
static GSList *hook_list = NULL;
static unsigned int plugin_dispatch_depth;
extern const struct prefs vars[]; /* cfgfiles.c */
@@ -250,7 +243,7 @@ plugin_list_add (zoitechat_context *ctx, char *filename, const char *name,
{
zoitechat_plugin *pl;
pl = g_new0 (zoitechat_plugin, 1);
pl = g_new (zoitechat_plugin, 1);
pl->handle = handle;
pl->filename = filename;
pl->context = ctx;
@@ -357,10 +350,6 @@ plugin_add (session *sess, char *filename, void *handle, void *init_func,
pl->zoitechat_emit_print_attrs = zoitechat_emit_print_attrs;
pl->zoitechat_event_attrs_create = zoitechat_event_attrs_create;
pl->zoitechat_event_attrs_free = zoitechat_event_attrs_free;
pl->zoitechat_register_capability = zoitechat_register_capability;
pl->zoitechat_hook_server_filtered = zoitechat_hook_server_filtered;
pl->zoitechat_hook_print_filtered = zoitechat_hook_print_filtered;
pl->zoitechat_hook_print_after = zoitechat_hook_print_after;
/* run zoitechat_plugin_init, if it returns 0, close the plugin */
if (((zoitechat_init_func *)init_func) (pl, &pl->name, &pl->desc, &pl->version, arg) == 0)
@@ -621,7 +610,6 @@ plugin_hook_run (session *sess, char *name, char *word[], char *word_eol[],
zoitechat_hook *hook;
int ret, eat = 0;
plugin_dispatch_depth++;
list = hook_list;
while (1)
{
@@ -631,22 +619,6 @@ plugin_hook_run (session *sess, char *name, char *word[], char *word_eol[],
hook = list->data;
next = list->next;
if (hook->context_filtered &&
(!is_session (sess) ||
(hook->connection_filter != -1 && hook->connection_filter != sess->server->id) ||
(hook->channel_filter && sess->server->p_cmp (hook->channel_filter, sess->channel))))
{
list = next;
continue; /* filter in C before entering an interpreter */
}
if (type == HOOK_PRINT_AFTER && hook->tag)
{
list = next;
continue; /* no recursive invocation of this same observer */
}
if (type == HOOK_PRINT_AFTER)
hook->tag = 1;
hook->pl->context = sess;
/* run the plugin's callback function */
@@ -655,7 +627,6 @@ plugin_hook_run (session *sess, char *name, char *word[], char *word_eol[],
case HOOK_COMMAND:
ret = ((zoitechat_cmd_cb *)hook->callback) (word, word_eol, hook->userdata);
break;
case HOOK_PRINT_AFTER:
case HOOK_PRINT_ATTRS:
ret = ((zoitechat_print_attrs_cb *)hook->callback) (word, attrs, hook->userdata);
break;
@@ -670,15 +641,6 @@ plugin_hook_run (session *sess, char *name, char *word[], char *word_eol[],
break;
}
if (type == HOOK_PRINT_AFTER)
{
hook->tag = 0;
if (!is_session (sess))
goto xit;
list = next;
continue; /* display already happened; observers cannot eat it */
}
if ((ret & ZOITECHAT_EAT_ZOITECHAT) && (ret & ZOITECHAT_EAT_PLUGIN))
{
eat = 1;
@@ -693,10 +655,6 @@ plugin_hook_run (session *sess, char *name, char *word[], char *word_eol[],
}
xit:
/* Nested callbacks can unhook entries still used by an outer dispatch.
* Keep both hook objects and list links alive until the outermost return. */
if (--plugin_dispatch_depth != 0)
return eat;
/* really remove deleted hooks now */
list = hook_list;
while (list)
@@ -761,15 +719,6 @@ plugin_emit_print (session *sess, char *word[], time_t server_time)
HOOK_PRINT | HOOK_PRINT_ATTRS);
}
void
plugin_emit_print_after (session *sess, char *word[], time_t server_time)
{
zoitechat_event_attrs attrs;
attrs.server_time_utc = server_time;
if (is_session (sess))
plugin_hook_run (sess, word[0], word, NULL, &attrs, HOOK_PRINT_AFTER);
}
int
plugin_emit_dummy_print (session *sess, char *name)
{
@@ -857,7 +806,7 @@ plugin_insert_hook (zoitechat_hook *new_hook)
break;
case HOOK_SERVER:
case HOOK_SERVER_ATTRS:
new_hook_type = HOOK_SERVER | HOOK_SERVER_ATTRS;
new_hook_type = HOOK_SERVER | HOOK_PRINT_ATTRS;
break;
default:
new_hook_type = new_hook->type;
@@ -1069,87 +1018,10 @@ zoitechat_unhook (zoitechat_plugin *ph, zoitechat_hook *hook)
g_free (hook->name); /* NULL for timers & fds */
g_free (hook->help_text); /* NULL for non-commands */
g_free (hook->channel_filter);
hook->channel_filter = NULL;
return hook->userdata;
}
zoitechat_hook *
zoitechat_hook_print_after (zoitechat_plugin *ph, const char *name, int pri,
int flags, zoitechat_print_attrs_cb *callback, void *userdata)
{
if (flags != 0 || !name || !callback)
return NULL;
return plugin_add_hook (ph, HOOK_PRINT_AFTER, pri, name, NULL, callback, 0, userdata);
}
static zoitechat_hook *
plugin_add_filtered_hook (zoitechat_plugin *ph, const char *name, int pri,
int flags, int connection_id, const char *channel,
int type, void *callback, void *userdata)
{
zoitechat_hook *hook;
if (flags != 0 || connection_id < -1 || !name || !callback)
return NULL;
hook = plugin_add_hook (ph, type, pri, name, NULL, callback, 0, userdata);
hook->context_filtered = TRUE;
hook->connection_filter = connection_id;
hook->channel_filter = g_strdup (channel);
return hook;
}
zoitechat_hook *
zoitechat_hook_server_filtered (zoitechat_plugin *ph, const char *name, int pri,
int flags, int connection_id, const char *channel,
zoitechat_serv_attrs_cb *callback, void *userdata)
{
return plugin_add_filtered_hook (ph, name, pri, flags, connection_id, channel,
HOOK_SERVER_ATTRS, callback, userdata);
}
zoitechat_hook *
zoitechat_hook_print_filtered (zoitechat_plugin *ph, const char *name, int pri,
int flags, int connection_id, const char *channel,
zoitechat_print_attrs_cb *callback, void *userdata)
{
return plugin_add_filtered_hook (ph, name, pri, flags, connection_id, channel,
HOOK_PRINT_ATTRS, callback, userdata);
}
zoitechat_hook *
zoitechat_register_capability (zoitechat_plugin *ph, const char *name, int flags,
int connection_id, void *userdata)
{
const unsigned char *p;
zoitechat_hook *hook;
if (flags != 0 || connection_id < -1 || !name || !name[0] || strlen (name) > 200 ||
strcmp (name, "sasl") == 0 || strcmp (name, "sts") == 0)
return NULL; /* authentication and transport policy belong to core */
for (p = (const unsigned char *)name; *p; p++)
if (!g_ascii_islower (*p) && !g_ascii_isdigit (*p) &&
*p != '-' && *p != '.' && *p != '/' && *p != '_')
return NULL;
hook = plugin_add_hook (ph, HOOK_CAPABILITY, 0, name, NULL, NULL, 0, userdata);
hook->cap_connection_id = connection_id;
return hook;
}
gboolean
plugin_requests_capability (server *serv, const char *name)
{
GSList *entry;
for (entry = hook_list; entry; entry = entry->next)
{
zoitechat_hook *hook = entry->data;
if (hook && hook->type == HOOK_CAPABILITY &&
(hook->cap_connection_id == -1 || hook->cap_connection_id == serv->id) &&
strcmp (hook->name, name) == 0)
return TRUE;
}
return FALSE;
}
zoitechat_hook *
zoitechat_hook_command (zoitechat_plugin *ph, const char *name, int pri,
zoitechat_cmd_cb *callb, const char *help_text, void *userdata)
@@ -1309,25 +1181,6 @@ zoitechat_get_info (zoitechat_plugin *ph, const char *id)
return text_find_format_string (e);
}
/* Report the comparison actually used by this connection, not an
* advertised mapping that the current core does not implement. */
if (strcmp (id, "casemapping") == 0)
{
if (!is_session (ph->context))
return NULL;
return ph->context->server->p_cmp == g_ascii_strcasecmp
? "ascii" : "rfc1459";
}
if (strcmp (id, "api_hook_print_after") == 0)
return "1";
if (strcmp (id, "api_context_filters") == 0)
return "1";
if (strcmp (id, "api_plugin_caps") == 0)
return "1";
hash = str_hash (id);
/* do the session independant ones first */
switch (hash)
-11
View File
@@ -150,15 +150,6 @@ struct _zoitechat_plugin
void (*zoitechat_event_attrs_free) (zoitechat_plugin *ph,
zoitechat_event_attrs *attrs);
/* Additive API slots: append only; old binary offsets are unchanged. */
zoitechat_hook *(*zoitechat_hook_print_after) (zoitechat_plugin *, const char *, int,
int, int (*)(char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_hook_server_filtered) (zoitechat_plugin *, const char *, int,
int, int, const char *, int (*)(char *[], char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_hook_print_filtered) (zoitechat_plugin *, const char *, int,
int, int, const char *, int (*)(char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_register_capability) (zoitechat_plugin *, const char *, int, int, void *);
/* PRIVATE FIELDS! */
void *handle; /* from dlopen */
char *filename; /* loaded from */
@@ -183,8 +174,6 @@ int plugin_emit_command (session *sess, char *name, char *word[], char *word_eol
int plugin_emit_server (session *sess, char *name, char *word[], char *word_eol[],
time_t server_time);
int plugin_emit_print (session *sess, char *word[], time_t server_time);
gboolean plugin_requests_capability (server *serv, const char *name);
void plugin_emit_print_after (session *sess, char *word[], time_t server_time);
int plugin_emit_dummy_print (session *sess, char *name);
int plugin_emit_keypress (session *sess, unsigned int state, unsigned int keyval, gunichar key);
GList* plugin_command_list(GList *tmp_list);
+5
View File
@@ -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
View File
@@ -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
+117
View File
@@ -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 ();
}
+3 -4
View File
@@ -1808,7 +1808,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));
@@ -2306,7 +2306,6 @@ text_emit (int index, session *sess, char *a, char *b, char *c, char *d,
if (!prefs.hex_away_omit_alerts || !sess->server->is_away)
sound_play_event (index);
display_event (sess, index, word, stripcolor_args, timestamp);
plugin_emit_print_after (sess, word, timestamp);
}
char *
@@ -2489,7 +2488,7 @@ sound_load_event (char *evt, char *file)
}
void
sound_load ()
sound_load (void)
{
int fd;
char buf[512];
@@ -2522,7 +2521,7 @@ sound_load ()
}
void
sound_save ()
sound_save (void)
{
int fd, i;
char buf[512];
+16 -2
View File
@@ -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++)
-33
View File
@@ -193,39 +193,10 @@ struct _zoitechat_plugin
zoitechat_event_attrs *(*zoitechat_event_attrs_create) (zoitechat_plugin *ph);
void (*zoitechat_event_attrs_free) (zoitechat_plugin *ph,
zoitechat_event_attrs *attrs);
/* Additive API slots: append only; old binary offsets are unchanged. */
zoitechat_hook *(*zoitechat_hook_print_after) (zoitechat_plugin *, const char *, int,
int, int (*)(char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_hook_server_filtered) (zoitechat_plugin *, const char *, int,
int, int, const char *, int (*)(char *[], char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_hook_print_filtered) (zoitechat_plugin *, const char *, int,
int, int, const char *, int (*)(char *[], zoitechat_event_attrs *, void *), void *);
zoitechat_hook *(*zoitechat_register_capability) (zoitechat_plugin *, const char *, int, int, void *);
};
#endif
/* api_hook_print_after=1: observer returns are ignored; flags must be zero. */
zoitechat_hook *zoitechat_hook_print_after (zoitechat_plugin *ph,
const char *name, int pri, int flags,
int (*callback) (char *word[], zoitechat_event_attrs *attrs, void *userdata),
void *userdata);
/* api_context_filters=1: connection_id -1 is any; NULL channel is any. */
zoitechat_hook *zoitechat_hook_server_filtered (zoitechat_plugin *ph,
const char *name, int pri, int flags, int connection_id, const char *channel,
int (*callback) (char *word[], char *word_eol[], zoitechat_event_attrs *attrs, void *userdata),
void *userdata);
zoitechat_hook *zoitechat_hook_print_filtered (zoitechat_plugin *ph,
const char *name, int pri, int flags, int connection_id, const char *channel,
int (*callback) (char *word[], zoitechat_event_attrs *attrs, void *userdata),
void *userdata);
/* api_plugin_caps=1: register before CAP LS; flags=0, connection_id=-1 for any. */
zoitechat_hook *zoitechat_register_capability (zoitechat_plugin *ph,
const char *name, int flags, int connection_id, void *userdata);
zoitechat_hook *
zoitechat_hook_command (zoitechat_plugin *ph,
const char *name,
@@ -445,10 +416,6 @@ zoitechat_pluginpref_list (zoitechat_plugin *ph,
#define zoitechat_hook_server ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_server)
#define zoitechat_hook_server_attrs ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_server_attrs)
#define zoitechat_hook_print ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_print)
#define zoitechat_register_capability ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_register_capability)
#define zoitechat_hook_server_filtered ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_server_filtered)
#define zoitechat_hook_print_filtered ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_print_filtered)
#define zoitechat_hook_print_after ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_print_after)
#define zoitechat_hook_print_attrs ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_print_attrs)
#define zoitechat_hook_timer ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_timer)
#define zoitechat_hook_fd ((ZOITECHAT_PLUGIN_HANDLE)->zoitechat_hook_fd)
+7
View File
@@ -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
+1
View File
@@ -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;
+19 -1
View File
@@ -21,6 +21,9 @@
#include <stdlib.h>
#include "fe-gtk.h"
#ifdef __APPLE__
#include "../common/macos.h"
#endif
#ifdef GDK_WINDOWING_WIN32
#include <gdk/gdkwin32.h>
@@ -372,7 +375,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 +502,15 @@ fe_args (int argc, char *argv[])
#endif
gtk_init (&argc, &argv);
#ifdef __APPLE__
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
+2 -2
View File
@@ -426,7 +426,7 @@ key_binding_is_builtin (struct key_binding *kb)
}
void
key_init ()
key_init (void)
{
if (key_load_kbs () == 1)
{
@@ -1219,7 +1219,7 @@ key_dialog_load (GtkListStore *store)
}
void
key_dialog_show ()
key_dialog_show (void)
{
GtkWidget *vbox, *box, *pane, *scroll;
GtkWidget *view, *xtext, *delete_button;
+1 -1
View File
@@ -336,7 +336,7 @@ ignore_stats_entry (GtkWidget * box, char *label, int value)
}
void
ignore_gui_open ()
ignore_gui_open (void)
{
GtkWidget *vbox, *box, *stat_box, *frame;
GtkWidget *view;
-34
View File
@@ -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)
{
+3
View File
@@ -1962,7 +1962,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));
+23
View File
@@ -53,6 +53,15 @@ zoitechat_gtk_deps = [
gtk_dep = dependency('gtk+-3.0', version: '>= 3.22')
# 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
zoitechat_theme_deps = [gtk_dep, zoitechat_common_dep]
if host_machine.system() != 'windows'
@@ -91,12 +100,18 @@ 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 += '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 +152,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,
@@ -278,3 +294,10 @@ test('Emoji Font Tests', emoji_font_tests,
protocol: 'tap',
timeout: 120,
)
xtext_tests = executable('xtext_tests',
['tests/test-xtext.c', '../common/hexcolor.c'],
include_directories: [config_h_include],
dependencies: [gtk_dep],
)
test('XText Tests', xtext_tests, protocol: 'tap', timeout: 120)
+51 -38
View File
@@ -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)
{
return 1;
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;
}
+38
View File
@@ -59,6 +59,8 @@ static struct zoitechatprefs setup_prefs;
static GtkWidget *cancel_button;
static GtkWidget *font_dialog = NULL;
static GtkWidget *setup_topicbar_multiline_toggle = NULL;
static GtkWidget *setup_wrap_align_label = NULL;
static GtkWidget *setup_wrap_align_combo = NULL;
enum
{
@@ -157,6 +159,14 @@ static const char *const langsmenu[] =
};
#endif
static const char *const wrapalignmenu[] =
{
N_("Start of line (default)"),
N_("Prefix (nick name)"),
N_("Message"),
NULL
};
static const setting appearance_settings[] =
{
{ST_HEADER, N_("General"),0,0,0},
@@ -172,6 +182,7 @@ static const setting appearance_settings[] =
{ST_TOGGLR, N_("Indent nick names"), P_OFFINTNL(hex_text_indent), N_("Make nick names right-justified"),0,0},
{ST_TOGGLE, N_ ("Show marker line"), P_OFFINTNL (hex_text_show_marker), N_ ("Insert a red line after the last read text."), 0, 0},
{ST_TOGGLE, N_("Keep search position when closing"), P_OFFINTNL(hex_text_search_keep_position), 0, 0, 0},
{ST_MENU, N_("Align wrapped lines under:"), P_OFFINTNL(hex_text_wrap_align), N_("Where wrapped lines start when nick names are not indented"), wrapalignmenu, 0},
{ST_HEADER, N_("Timestamps"),0,0,0},
{ST_TOGGLE, N_("Enable timestamps"), P_OFFINTNL(hex_stamp_text),0,0,1},
@@ -857,6 +868,20 @@ setup_topicbar_inline_toggled_cb (GtkToggleButton *but, gpointer userdata)
!gtk_toggle_button_get_active (but));
}
static void
setup_text_indent_toggled_cb (GtkToggleButton *but, gpointer userdata)
{
gboolean sens = !gtk_toggle_button_get_active (but);
(void) userdata;
/* wrapped line alignment only applies without nick indentation */
if (setup_wrap_align_label)
gtk_widget_set_sensitive (setup_wrap_align_label, sens);
if (setup_wrap_align_combo)
gtk_widget_set_sensitive (setup_wrap_align_combo, sens);
}
static void
setup_create_toggleR (GtkWidget *tab, int row, const setting *set)
{
@@ -867,6 +892,9 @@ setup_create_toggleR (GtkWidget *tab, int row, const setting *set)
setup_get_int (&setup_prefs, set));
g_signal_connect (G_OBJECT (wid), "toggled",
G_CALLBACK (setup_toggle_cb), (gpointer)set);
if (set->offset == STRUCT_OFFSET_INT (struct zoitechatprefs, hex_text_indent))
g_signal_connect (G_OBJECT (wid), "toggled",
G_CALLBACK (setup_text_indent_toggled_cb), NULL);
if (set->tooltip)
gtk_widget_set_tooltip_text (wid, _(set->tooltip));
setup_table_attach (tab, wid, 4, 5, row, row + 1, TRUE, FALSE,
@@ -1279,6 +1307,16 @@ setup_create_menu (GtkWidget *table, int row, const setting *set)
setup_table_attach (table, cbox, 3, 6, row, row + 1, TRUE, FALSE,
SETUP_ALIGN_FILL, SETUP_ALIGN_FILL, 0, 0);
if (set->offset == STRUCT_OFFSET_INT (struct zoitechatprefs, hex_text_wrap_align))
{
setup_wrap_align_label = wid;
setup_wrap_align_combo = cbox;
g_object_add_weak_pointer (G_OBJECT (wid), (gpointer *)&setup_wrap_align_label);
g_object_add_weak_pointer (G_OBJECT (cbox), (gpointer *)&setup_wrap_align_combo);
gtk_widget_set_sensitive (wid, !setup_prefs.hex_text_indent);
gtk_widget_set_sensitive (cbox, !setup_prefs.hex_text_indent);
}
}
static void
+16 -1
View File
@@ -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)
+597
View File
@@ -0,0 +1,597 @@
/* XText regression tests. Distributed under GPL-2.0-or-later; see COPYING.
* Include the implementation to check layout/selection invariants without
* adding test-only symbols to the widget's public API. Application services
* are stubbed; GTK, Pango, Cairo and the widget itself are exercised normally. */
#include "../xtext.c"
#include <glib/gstdio.h>
struct zoitechatprefs prefs;
void emoji_font_apply (GtkWidget *widget) { }
int url_last (int *start, int *end) { return 0; }
gsize strftime_utf8 (char *dest, gsize size, const char *format, time_t stamp)
{
return strftime (dest, size, format, localtime (&stamp));
}
int xtext_get_stamp_str (time_t stamp, char **out)
{
*out = g_strdup ("[12:34] ");
return strlen (*out);
}
void theme_get_xtext_colors_for_widget (GtkWidget *widget, XTextColor *colors, size_t len)
{
size_t i;
for (i = 0; i < len; i++)
{
colors[i].red = colors[i].green = colors[i].blue = 0.0;
colors[i].alpha = 1.0;
}
colors[XTEXT_FG].red = colors[XTEXT_FG].green = colors[XTEXT_FG].blue = 1.0;
colors[XTEXT_MARK_BG].blue = 1.0;
colors[XTEXT_MARK_FG] = colors[XTEXT_FG];
colors[XTEXT_MARKER].red = 1.0;
}
typedef struct { GtkWidget *window; GtkXText *xtext; } Fixture;
static void drain_events (void)
{
while (g_main_context_iteration (NULL, FALSE)) { }
}
static void fixture_setup (Fixture *f, gconstpointer data)
{
XTextColor colors[XTEXT_COLS];
GtkWidget *scrolled = gtk_scrolled_window_new (NULL, NULL);
theme_get_xtext_colors_for_widget (NULL, colors, G_N_ELEMENTS (colors));
f->window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
gtk_window_set_default_size (GTK_WINDOW (f->window), 640, 360);
f->xtext = GTK_XTEXT (gtk_xtext_new (colors, TRUE));
g_object_ref (f->xtext);
gtk_container_add (GTK_CONTAINER (scrolled), GTK_WIDGET (f->xtext));
gtk_container_add (GTK_CONTAINER (f->window), scrolled);
g_assert_true (gtk_xtext_set_font (f->xtext, "monospace 12"));
gtk_widget_show_all (f->window);
drain_events ();
}
static void fixture_teardown (Fixture *f, gconstpointer data)
{
gtk_widget_destroy (f->window);
g_object_unref (f->xtext);
drain_events ();
g_assert_null (image_pending_list);
}
static void append (Fixture *f, const char *text)
{
gtk_xtext_append (f->xtext->buffer, (guchar *)text, -1, 1234);
}
static cairo_surface_t *snapshot (Fixture *f, gboolean partial)
{
GtkWidget *widget = GTK_WIDGET (f->xtext);
int width = gtk_widget_get_allocated_width (widget);
int height = gtk_widget_get_allocated_height (widget);
cairo_surface_t *surface = cairo_image_surface_create (CAIRO_FORMAT_ARGB32, width, height);
cairo_t *cr = cairo_create (surface);
if (partial)
{
cairo_rectangle (cr, 0, f->xtext->fontsize, width, height - f->xtext->fontsize);
cairo_clip (cr);
}
gtk_widget_draw (widget, cr);
g_assert_cmpint (cairo_status (cr), ==, CAIRO_STATUS_SUCCESS);
cairo_destroy (cr);
cairo_surface_flush (surface);
return surface;
}
static void test_empty_and_bounded_append (Fixture *f, gconstpointer data)
{
const guchar bytes[] = {'a', 'b', 'c'}; /* deliberately no terminating NUL */
append (f, "");
g_assert_cmpstr ((char *)f->xtext->buffer->text_last->str, ==, "");
g_assert_cmpint (f->xtext->buffer->text_last->subline_count, ==, 1);
append (f, "\n");
g_assert_cmpint (f->xtext->buffer->text_last->str_len, ==, 0);
gtk_xtext_append (f->xtext->buffer, (guchar *)bytes, sizeof (bytes), 1234);
g_assert_cmpstr ((char *)f->xtext->buffer->text_last->str, ==, "abc");
gtk_xtext_append_indent (f->xtext->buffer, (guchar *)"", 0, (guchar *)"", 0, 1234);
g_assert_cmpstr ((char *)f->xtext->buffer->text_last->str, ==, " ");
}
static void test_utf8_limits (Fixture *f, gconstpointer data)
{
GString *text = g_string_new (NULL);
int i;
for (i = 0; i < 1500; i++) g_string_append (text, "猫");
gtk_xtext_append (f->xtext->buffer, (guchar *)text->str, text->len, 1234);
g_assert_cmpint (f->xtext->buffer->text_last->str_len, ==, 4095);
g_assert_true (g_utf8_validate ((char *)f->xtext->buffer->text_last->str, -1, NULL));
gtk_xtext_append_indent (f->xtext->buffer, (guchar *)text->str, text->len,
(guchar *)"猫", -1, 1234);
g_assert_cmpint (f->xtext->buffer->text_last->str_len, <=, 4095);
g_assert_true (g_utf8_validate ((char *)f->xtext->buffer->text_last->str, -1, NULL));
gtk_xtext_append (f->xtext->buffer, (guchar *)"hello 猫", 8, 1234);
g_assert_cmpstr ((char *)f->xtext->buffer->text_last->str, ==, "hello ");
gtk_xtext_append (f->xtext->buffer, (guchar *)"ok\xff" "bad", 6, 1234);
g_assert_cmpstr ((char *)f->xtext->buffer->text_last->str, ==, "ok");
g_string_free (text, TRUE);
}
static void test_null_empty_prefix (Fixture *f, gconstpointer data)
{
xtext_buffer *buf = f->xtext->buffer;
/* PrintTextLine passes NULL/0 for indented server text without a tab. */
gtk_xtext_append_indent (buf, NULL, 0, (guchar *)"server notice", -1, 1234);
g_assert_cmpstr ((char *)buf->text_last->str, ==, " server notice");
g_assert_cmpint (buf->text_last->left_len, ==, 0);
g_assert_cmpint (buf->text_last->stamp, ==, 1234);
gtk_xtext_append_indent (buf, (guchar *)"nick", -1, NULL, 0, 1234);
g_assert_cmpstr ((char *)buf->text_last->str, ==, "nick ");
gtk_xtext_append_indent (buf, NULL, 0, NULL, 0, 1234);
g_assert_cmpstr ((char *)buf->text_last->str, ==, " ");
g_assert_cmpint (buf->num_lines, ==, 3);
cairo_surface_destroy (snapshot (f, FALSE));
}
static void test_wrapping (Fixture *f, gconstpointer data)
{
xtext_buffer *buf = f->xtext->buffer;
textentry *ent;
int start = 0, i;
append (f, "猫猫猫猫猫猫猫猫猫猫");
ent = buf->text_last;
/* A character wider than the row must still be kept whole. */
buf->window_width = 3;
gtk_xtext_lines_taken (buf, ent);
g_assert_cmpint (ent->subline_count, ==, 10);
for (i = 0; i < ent->subline_count; i++)
{
int end = gtk_xtext_subline_end (ent, i);
g_assert_cmpint (end, >, start);
g_assert_true (g_utf8_validate ((char *)ent->str + start, end - start, NULL));
start = end;
}
buf->window_width = 120;
gtk_xtext_lines_taken (buf, ent);
g_assert_cmpint (ent->subline_count, >, 1);
buf->window_width = 640;
gtk_xtext_lines_taken (buf, ent);
g_assert_cmpint (ent->subline_count, ==, 1);
}
static void test_font_isolation (Fixture *f, gconstpointer data)
{
Fixture second;
int original = gtk_xtext_text_width (f->xtext, (guchar *)"WWii hello", 10);
fixture_setup (&second, NULL);
g_assert_true (gtk_xtext_set_font (second.xtext, "sans 30"));
g_assert_cmpint (gtk_xtext_text_width (second.xtext, (guchar *)"WWii hello", 10), >, original);
g_assert_cmpint (gtk_xtext_text_width (f->xtext, (guchar *)"WWii hello", 10), ==, original);
fixture_teardown (&second, NULL);
g_assert_cmpint (gtk_xtext_text_width (f->xtext, (guchar *)"WWii hello", 10), ==, original);
}
static void test_wide_metrics (Fixture *f, gconstpointer data)
{
char *text = g_strnfill (4000, 'W');
g_assert_true (gtk_xtext_set_font (f->xtext, "sans 40"));
append (f, text);
g_assert_cmpint (f->xtext->buffer->text_last->str_width, >, 65535);
g_assert_cmpint (f->xtext->buffer->text_last->subline_count, >, 1);
g_free (text);
}
static void test_formatting (Fixture *f, gconstpointer data)
{
const char *formatted = "a\002bold\002 \035italic\035 \00304,02red\017 \037u\037 \036s\036 \026r\026";
char *plain;
GSList *runs = NULL;
int len;
plain = (char *)gtk_xtext_strip_color ((guchar *)formatted, strlen (formatted), NULL, &len, &runs, FALSE);
g_assert_cmpstr (plain, ==, "abold italic red u s r");
g_assert_cmpint (len, ==, strlen (plain));
g_assert_cmpint (((offlen_t *)runs->data)->off, ==, 0);
g_slist_free_full (runs, g_free);
g_free (plain);
plain = (char *)gtk_xtext_strip_color ((guchar *)"a\010secret\010b", 10, NULL, &len, NULL, TRUE);
g_assert_cmpstr (plain, ==, "ab");
g_free (plain);
append (f, formatted);
gtk_xtext_append_indent (f->xtext->buffer, (guchar *)"nick", -1, (guchar *)formatted, -1, 1234);
f->xtext->auto_indent = TRUE;
f->xtext->buffer->time_stamp = TRUE;
f->xtext->buffer->indent = 130;
gtk_xtext_recalc_widths (f->xtext->buffer, FALSE);
cairo_surface_destroy (snapshot (f, FALSE));
g_assert_cmpstr ((char *)f->xtext->buffer->text_last->str, ==,
"nick a\002bold\002 \035italic\035 \00304,02red\017 \037u\037 \036s\036 \026r\026");
}
static void select_entries (Fixture *f, textentry *start, textentry *end)
{
textentry *ent;
for (ent = start; ent; ent = ent->next)
{
ent->mark_start = 0;
ent->mark_end = ent->str_len;
if (ent == end) break;
}
gtk_xtext_selection_render (f->xtext, start, end);
f->xtext->selection_buffer = f->xtext->buffer;
}
static void test_selection_and_clipboard (Fixture *f, gconstpointer data)
{
char *selected;
int len;
append (f, "");
append (f, "\002bold\002 猫");
append (f, "last");
select_entries (f, f->xtext->buffer->text_first, f->xtext->buffer->text_last);
selected = gtk_xtext_selection_get_text (f->xtext, &len);
g_assert_cmpstr (selected, ==, "\nbold 猫\nlast");
g_assert_cmpint (len, ==, strlen (selected));
g_free (selected);
f->xtext->color_paste = TRUE;
f->xtext->mark_stamp = TRUE;
selected = gtk_xtext_selection_get_text (f->xtext, &len);
g_assert_cmpstr (selected, ==, "\n[12:34] \002bold\002 猫\n[12:34] last");
g_free (selected);
f->xtext->color_paste = f->xtext->mark_stamp = FALSE;
gtk_xtext_copy_selection (f->xtext);
selected = gtk_clipboard_wait_for_text (gtk_widget_get_clipboard (GTK_WIDGET (f->xtext), GDK_SELECTION_CLIPBOARD));
g_assert_cmpstr (selected, ==, "\nbold 猫\nlast");
g_free (selected);
selected = gtk_clipboard_wait_for_text (gtk_widget_get_clipboard (GTK_WIDGET (f->xtext), GDK_SELECTION_PRIMARY));
g_assert_cmpstr (selected, ==, "\nbold 猫\nlast");
g_free (selected);
gtk_xtext_unselect (f->xtext);
g_assert_false (gtk_xtext_is_selecting (f->xtext));
}
static void test_search (Fixture *f, gconstpointer data)
{
GError *error = NULL;
textentry *first;
xtext_buffer *buf = f->xtext->buffer;
append (f, "one \002needle\002 then NEEDLE");
first = buf->text_last;
append (f, "second needle");
g_assert_true (gtk_xtext_search (f->xtext, "needle", highlight, &error) == first);
g_assert_no_error (error);
g_assert_cmpint (g_list_length (first->marks), ==, 2);
g_assert_true (gtk_xtext_search (f->xtext, "needle", highlight, &error) == first);
g_assert_true (gtk_xtext_search (f->xtext, "needle", highlight, &error) == buf->text_last);
gtk_xtext_search (f->xtext, "", 0, &error);
g_assert_null (buf->search_found);
g_assert_true (gtk_xtext_search (f->xtext, "needle", backward | case_match, &error) == buf->text_last);
gtk_xtext_search (f->xtext, "n[a-z]+", regexp | case_match, &error);
g_assert_no_error (error);
g_assert_nonnull (buf->search_re);
gtk_xtext_search (f->xtext, "[", regexp, &error);
g_assert_error (error, G_REGEX_ERROR, G_REGEX_ERROR_UNTERMINATED_CHARACTER_CLASS);
g_clear_error (&error);
/* A no-match search must also release its regex/text on clear. */
gtk_xtext_search (f->xtext, "nomatch", regexp, &error);
g_assert_null (buf->search_found);
gtk_xtext_clear (buf, 0);
g_assert_null (buf->search_re);
g_assert_null (buf->search_text);
}
static void test_follow_search (Fixture *f, gconstpointer data)
{
xtext_buffer *buf = f->xtext->buffer;
append (f, "match first");
gtk_xtext_search (f->xtext, "match", 0, NULL);
append (f, "match second");
gtk_xtext_search (f->xtext, NULL, follow, NULL);
g_assert_cmpint (g_list_length (buf->search_found), ==, 2);
GList *current = buf->curmark;
gtk_xtext_search (f->xtext, NULL, 0, NULL);
gtk_xtext_search (f->xtext, NULL, follow, NULL);
g_assert_cmpint (g_list_length (buf->search_found), ==, 2);
g_assert_true (buf->curmark == current);
append (f, "match third");
g_assert_cmpint (g_list_length (buf->search_found), ==, 3);
}
static void test_search_unicode_offsets (Fixture *f, gconstpointer data)
{
xtext_buffer *buf = f->xtext->buffer;
offsets_t mark;
append (f, "İ \002NEEDLE\002 Straße");
gtk_xtext_search (f->xtext, "needle", 0, NULL);
mark.u = GPOINTER_TO_UINT (buf->text_first->marks->data);
g_assert_cmpint (mark.o.start, ==, 4);
/* The established color-offset mapping includes the closing control. */
g_assert_cmpint (mark.o.end, ==, 11);
gtk_xtext_search (f->xtext, "ss", 0, NULL);
mark.u = GPOINTER_TO_UINT (buf->text_first->marks->data);
g_assert_cmpint (mark.o.start, ==, 16);
g_assert_cmpint (mark.o.end, ==, 18);
gtk_xtext_clear (buf, 0);
append (f, "ſ needle");
gtk_xtext_search (f->xtext, "needle", 0, NULL);
mark.u = GPOINTER_TO_UINT (buf->text_first->marks->data);
g_assert_cmpint (mark.o.start, ==, 3);
g_assert_cmpint (mark.o.end, ==, 9);
}
static void test_rawlog_hidden_text (Fixture *f, gconstpointer data)
{
int hidden, visible;
append (f, "a\010secret\010b");
hidden = f->xtext->buffer->text_last->str_width;
f->xtext->ignore_hidden = TRUE;
gtk_xtext_recalc_widths (f->xtext->buffer, TRUE);
visible = f->xtext->buffer->text_last->str_width;
g_assert_cmpint (visible, >, hidden);
g_assert_cmpint (visible, ==, gtk_xtext_text_width (f->xtext, (guchar *)"asecretb", 8));
}
static void test_save (Fixture *f, gconstpointer data)
{
char *path, *saved;
GError *error = NULL;
int fd = g_file_open_tmp ("zoitechat-xtext-XXXXXX", &path, &error);
g_assert_no_error (error);
append (f, "\002bold\002 猫");
append (f, "");
append (f, "last");
gtk_xtext_save (f->xtext, fd);
close (fd);
g_assert_true (g_file_get_contents (path, &saved, NULL, &error));
g_assert_no_error (error);
g_assert_cmpstr (saved, ==, "bold 猫\n\nlast\n");
g_free (saved);
g_unlink (path);
g_free (path);
}
static void test_scrollable_ownership (Fixture *f, gconstpointer data)
{
GtkAdjustment *horizontal = GTK_ADJUSTMENT (gtk_adjustment_new (0, 0, 1, 1, 1, 1));
GtkAdjustment *vertical = GTK_ADJUSTMENT (gtk_adjustment_new (0, 0, 100, 1, 10, 10));
GtkAdjustment *old = g_object_ref (f->xtext->adj);
g_object_set (f->xtext, "hadjustment", horizontal, "vadjustment", vertical, NULL);
g_assert_false (g_object_is_floating (horizontal));
g_assert_false (g_object_is_floating (vertical));
g_assert_true (f->xtext->hadj == horizontal);
g_assert_true (f->xtext->adj == vertical);
/* A replaced adjustment must no longer call into the widget. */
g_assert_cmpuint (g_signal_handler_find (old, G_SIGNAL_MATCH_DATA, 0, 0, NULL, NULL, f->xtext), ==, 0);
g_signal_emit_by_name (f->xtext, "set-scroll-adjustments", horizontal, vertical);
g_assert_true (f->xtext->adj == vertical);
g_object_set (f->xtext, "hadjustment", NULL, "vadjustment", NULL, NULL);
g_assert_true (GTK_IS_ADJUSTMENT (f->xtext->hadj));
g_assert_true (GTK_IS_ADJUSTMENT (f->xtext->adj));
g_object_unref (old);
}
static void test_clear_and_marker (Fixture *f, gconstpointer data)
{
xtext_buffer *buf = f->xtext->buffer;
append (f, "first"); append (f, "second"); append (f, "third");
buf->marker_pos = buf->text_first;
buf->marker_state = MARKER_IS_SET;
gtk_xtext_clear (buf, 1);
g_assert_cmpstr ((char *)buf->text_first->str, ==, "second");
g_assert_cmpint (buf->marker_state, ==, MARKER_RESET_BY_CLEAR);
gtk_xtext_clear (buf, -1);
g_assert_cmpstr ((char *)buf->text_last->str, ==, "second");
select_entries (f, buf->text_first, buf->text_last);
f->xtext->hilight_ent = buf->text_first;
gtk_xtext_clear (buf, 0);
g_assert_null (f->xtext->hilight_ent);
g_assert_null (buf->pagetop_ent);
g_assert_null (buf->last_ent_start);
g_assert_cmpint (buf->num_lines, ==, 0);
g_assert_true (gtk_xtext_is_empty (buf));
append (f, "after clear");
g_assert_cmpint (buf->num_lines, ==, 1);
}
static void test_scrollback_limit (Fixture *f, gconstpointer data)
{
int i;
xtext_buffer *buf = f->xtext->buffer;
gtk_xtext_set_max_lines (f->xtext, 10);
for (i = 0; i < 100; i++) append (f, "line");
g_assert_cmpint (buf->num_lines, ==, 10);
g_assert_null (buf->text_first->prev);
g_assert_null (buf->text_last->next);
g_assert_cmpfloat (gtk_adjustment_get_value (f->xtext->adj), >=, 0);
}
static void test_follow_trim_oversized_entry (Fixture *f, gconstpointer data)
{
char *long_text = g_strnfill (4000, 'W');
gtk_xtext_set_max_lines (f->xtext, 3);
append (f, "needle");
gtk_xtext_search (f->xtext, "W", follow, NULL);
gtk_xtext_clear (f->xtext->buffer, 1);
append (f, long_text);
g_assert_true (gtk_xtext_is_empty (f->xtext->buffer));
g_assert_null (f->xtext->buffer->search_found);
g_free (long_text);
append (f, "after oversized");
gtk_xtext_clear (f->xtext->buffer, G_MININT);
g_assert_true (gtk_xtext_is_empty (f->xtext->buffer));
}
static void test_stress (Fixture *f, gconstpointer data)
{
static const char *tokens[] = {"text ", "猫", "😀", "\002bold\002", "\00304,02red\017", "\010hidden\010", "\037u\037", "\036s\036"};
GRand *random = g_rand_new_with_seed (0x78746578);
xtext_buffer *buf = f->xtext->buffer;
int i;
gtk_xtext_set_max_lines (f->xtext, 80);
for (i = 0; i < 2000; i++)
{
GString *text = g_string_new (NULL);
textentry *ent;
int j, lines = 0;
for (j = 0; j < 30; j++)
g_string_append (text, tokens[g_rand_int_range (random, 0, G_N_ELEMENTS (tokens))]);
if (i % 2)
gtk_xtext_append_indent (buf, (guchar *)"nick", -1, (guchar *)text->str, text->len, 1234);
else
gtk_xtext_append (buf, (guchar *)text->str, text->len, 1234);
g_string_free (text, TRUE);
if (i % 29 == 0) gtk_xtext_search (f->xtext, "text", follow | highlight, NULL);
if (i % 31 == 0) gtk_xtext_clear (buf, 1);
if (i % 37 == 0) gtk_xtext_clear (buf, -1);
if (i % 101 == 0) gtk_xtext_clear (buf, 0);
for (ent = buf->text_first; ent; ent = ent->next)
{
g_assert_true (g_utf8_validate ((char *)ent->str, ent->str_len, NULL));
g_assert_cmpint (ent->subline_count, >, 0);
lines += ent->subline_count;
}
g_assert_cmpint (buf->num_lines, ==, lines);
if (i % 43 == 0) cairo_surface_destroy (snapshot (f, FALSE));
}
g_rand_free (random);
}
static void test_buffers (Fixture *f, gconstpointer data)
{
xtext_buffer *original = f->xtext->buffer;
xtext_buffer *second = gtk_xtext_buffer_new (f->xtext);
append (f, "original");
gtk_xtext_append (second, (guchar *)"alternate", -1, 1234);
gtk_xtext_buffer_show (f->xtext, second, TRUE);
g_assert_true (f->xtext->buffer == second);
cairo_surface_destroy (snapshot (f, FALSE));
gtk_xtext_buffer_show (f->xtext, original, TRUE);
g_assert_cmpstr ((char *)f->xtext->buffer->text_first->str, ==, "original");
gtk_xtext_buffer_free (second);
}
static void test_partial_draw_after_scroll (Fixture *f, gconstpointer data)
{
cairo_surface_t *partial, *full;
int i, stride, height, y;
for (i = 0; i < 80; i++)
{
char *line = g_strdup_printf ("line %d — 猫 \002bold\002", i);
append (f, line);
g_free (line);
}
cairo_surface_destroy (snapshot (f, FALSE));
gtk_adjustment_set_value (f->xtext->adj, 12.5);
partial = snapshot (f, TRUE);
full = snapshot (f, FALSE);
stride = cairo_image_surface_get_stride (full);
height = cairo_image_surface_get_height (full);
for (y = f->xtext->fontsize; y < height; y++)
g_assert_cmpmem (cairo_image_surface_get_data (partial) + y * stride, stride,
cairo_image_surface_get_data (full) + y * stride, stride);
cairo_surface_destroy (partial);
cairo_surface_destroy (full);
}
static void test_image_lifecycle (Fixture *f, gconstpointer data)
{
GdkPixbuf *image = gdk_pixbuf_new (GDK_COLORSPACE_RGB, TRUE, 8, 800, 600);
xtext_buffer *buf = f->xtext->buffer;
textentry *ent;
guint handle;
int before;
gdk_pixbuf_fill (image, 0xff0000ff);
append (f, "image");
ent = buf->text_last;
before = buf->num_lines;
handle = gtk_xtext_image_load_begin (f->xtext, ent, "https://example.invalid/image.png");
g_assert_cmpuint (handle, !=, 0);
g_assert_cmpuint (gtk_xtext_image_load_begin (f->xtext, ent, "duplicate"), ==, 0);
g_assert_true (gtk_xtext_image_load_finish (handle, image));
g_assert_cmpint (buf->num_lines, >, before);
g_assert_true (gtk_xtext_entry_has_image (f->xtext, ent));
g_assert_cmpint (gdk_pixbuf_get_width (ent->image), <=, XTEXT_IMAGE_MAX_WIDTH);
g_assert_true (gtk_xtext_entry_get_full_image (f->xtext, ent) == image);
cairo_surface_destroy (snapshot (f, FALSE));
g_assert_true (gtk_xtext_image_remove (f->xtext, ent));
g_assert_cmpint (buf->num_lines, ==, before);
handle = gtk_xtext_image_load_begin (f->xtext, ent, "late");
gtk_xtext_clear (buf, 0);
g_assert_false (gtk_xtext_image_load_finish (handle, image));
g_assert_false (gtk_xtext_entry_has_image (f->xtext, ent));
g_assert_null (gtk_xtext_entry_get_full_image (f->xtext, ent));
g_object_unref (image);
}
static void test_background (Fixture *f, gconstpointer data)
{
cairo_surface_t *background = cairo_image_surface_create (CAIRO_FORMAT_ARGB32, 32, 32);
cairo_t *cr = cairo_create (background);
cairo_set_source_rgb (cr, 0.2, 0.3, 0.4);
cairo_paint (cr);
cairo_destroy (cr);
gtk_xtext_set_background (f->xtext, background);
cairo_surface_destroy (background);
gtk_xtext_set_background (f->xtext, f->xtext->background_surface);
append (f, "background text");
cairo_surface_destroy (snapshot (f, FALSE));
gtk_xtext_set_background (f->xtext, NULL);
cairo_surface_destroy (snapshot (f, FALSE));
}
static void test_destroy_pending_sources (Fixture *f, gconstpointer data)
{
guint image;
append (f, "pending");
image = gtk_xtext_image_load_begin (f->xtext, f->xtext->buffer->text_last, "late");
f->xtext->io_tag = g_timeout_add (10000, gtk_xtext_adjustment_timeout, f->xtext);
f->xtext->add_io_tag = g_timeout_add (10000, gtk_xtext_render_page_timeout, f->xtext);
f->xtext->scroll_tag = g_timeout_add (10000, gtk_xtext_scrollup_timeout, f->xtext);
gtk_widget_destroy (f->window);
g_assert_cmpint (f->xtext->io_tag, ==, 0);
g_assert_cmpint (f->xtext->add_io_tag, ==, 0);
g_assert_cmpint (f->xtext->scroll_tag, ==, 0);
g_assert_false (gtk_xtext_image_load_finish (image, NULL));
/* GtkWidget destruction must be safe if GObject dispose runs again. */
g_object_run_dispose (G_OBJECT (f->xtext));
f->window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
}
int main (int argc, char **argv)
{
g_test_init (&argc, &argv, NULL);
if (!gtk_init_check (&argc, &argv))
{
g_test_message ("GTK display unavailable; run under Xvfb for widget coverage");
return 77;
}
prefs.hex_gui_mouse_scroll_speed = 10;
#define ADD(name, function) g_test_add ("/xtext/" name, Fixture, NULL, fixture_setup, function, fixture_teardown)
ADD ("append/empty-bounded", test_empty_and_bounded_append);
ADD ("append/utf8-limits", test_utf8_limits);
ADD ("append/null-empty-prefix", test_null_empty_prefix);
ADD ("layout/wrapping", test_wrapping);
ADD ("layout/font-isolation", test_font_isolation);
ADD ("layout/wide-metrics", test_wide_metrics);
ADD ("render/formatting-timestamps", test_formatting);
ADD ("selection/clipboard", test_selection_and_clipboard);
ADD ("search/navigation-regex-clear", test_search);
ADD ("search/follow", test_follow_search);
ADD ("search/unicode-offsets", test_search_unicode_offsets);
ADD ("render/rawlog-hidden", test_rawlog_hidden_text);
ADD ("buffer/save", test_save);
ADD ("lifecycle/scrollable-ownership", test_scrollable_ownership);
ADD ("buffer/clear-marker", test_clear_and_marker);
ADD ("buffer/scrollback-limit", test_scrollback_limit);
ADD ("buffer/follow-trim-oversized", test_follow_trim_oversized_entry);
ADD ("buffer/stress", test_stress);
ADD ("buffer/switch", test_buffers);
ADD ("render/partial-scroll", test_partial_draw_after_scroll);
ADD ("images/lifecycle", test_image_lifecycle);
ADD ("render/background", test_background);
ADD ("lifecycle/pending-sources", test_destroy_pending_sources);
#undef ADD
return g_test_run ();
}
+2 -2
View File
@@ -92,7 +92,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);
@@ -486,7 +486,7 @@ pevent_hlist_treeview_new (GtkWidget *box)
}
void
pevent_dialog_show ()
pevent_dialog_show (void)
{
GtkWidget *vbox, *hbox, *wid, *pane, *preview_pane;
XTextColor xtext_palette[XTEXT_COLS];
@@ -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)
{
+1 -1
View File
@@ -187,7 +187,7 @@ populate_cb (char *urltext, gpointer userdata)
}
void
url_opengui ()
url_opengui (void)
{
GtkWidget *vbox, *hbox, *view;
char buf[128];
+598 -1091
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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
View File
@@ -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);
-2
View File
@@ -90,7 +90,6 @@
<MSWindowsTheme Include="$(DepsRoot)\share\themes\MS-Windows\**\*" />
<HicolorIcons Include="$(DepsRoot)\share\icons\hicolor\index.theme" />
<HicolorIcons Include="$(DepsRoot)\share\icons\hicolor\**\*.*" />
<PythonAdditiveApis Include="..\..\plugins\python\_zoitechat_*.py" />
</ItemGroup>
<Copy SourceFiles="@(None)" DestinationFolder="$(ZoiteChatRel)" />
@@ -116,7 +115,6 @@
<Copy SourceFiles="..\..\plugins\python\hexchat.py" DestinationFolder="$(ZoiteChatRel)\python" Condition="'$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true'" />
<Copy SourceFiles="..\..\plugins\python\zoitechat.py" DestinationFolder="$(ZoiteChatRel)\python" Condition="'$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true'" />
<Copy SourceFiles="..\..\plugins\python\_zoitechat.py" DestinationFolder="$(ZoiteChatRel)\python" Condition="'$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true'" />
<Copy SourceFiles="@(PythonAdditiveApis)" DestinationFolder="$(ZoiteChatRel)\python" Condition="('$(Python3Enabled)'=='true' or '$(Python38Enabled)'=='true') and '@(PythonAdditiveApis)' != ''" />
<WriteLinesToFile File="$(ZoiteChatRel)portable-mode" Lines="2" Overwrite="true" />