diff --git a/packaging/macos/bundle.py b/packaging/macos/bundle.py
index b774d3f7..b35ea468 100644
--- a/packaging/macos/bundle.py
+++ b/packaging/macos/bundle.py
@@ -12,6 +12,7 @@ import re
import shutil
import subprocess
import sys
+import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[2]
MACH_MAGIC = {bytes.fromhex(h) for h in ("feedface", "cefaedfe", "feedfacf", "cffaedfe", "cafebabe", "bebafeca", "cafebabf", "bfbafeca")}
@@ -123,6 +124,29 @@ def copy_tree(source: Path, target: Path) -> None:
shutil.copytree(source, target, dirs_exist_ok=True, symlinks=False)
+def write_fontconfig(resources: Path, fontconfig_prefix: Path) -> None:
+ # Noto Color Emoji has fixed-size color bitmaps. Fontconfig supplies the
+ # requested-size/strike-size matrix; without it glyphs render at 109px.
+ rule = fontconfig_prefix / "share/fontconfig/conf.avail/10-scale-bitmap-fonts.conf"
+ if not rule.is_file():
+ raise RuntimeError(f"Required Fontconfig bitmap scaling rules not found: {rule}")
+ resources.mkdir(parents=True, exist_ok=True)
+ config = ET.fromstring('''
+ /System/Library/Fonts/Library/Fonts~/Library/Fonts
+ fontconfig
+ sans-serifHelvetica
+ monospaceMenlo
+
+''')
+ # Embed the standard rules so they work after relocation without depending
+ # on Homebrew or Fontconfig's host configuration search paths.
+ for element in reversed(list(ET.parse(rule).getroot())):
+ config.insert(0, element)
+ (resources / "fonts.conf").write_text(
+ '\n\n' +
+ ET.tostring(config, encoding="unicode") + "\n", encoding="utf-8")
+
+
def audit(app: Path) -> dict[str, object]:
binary = app / "Contents/MacOS/zoitechat"
architectures = set(run("lipo", "-archs", binary).split())
@@ -180,7 +204,7 @@ def package(stage: Path, app: Path, build_number: str) -> None:
seeds.append((source, resources / "lib/zoitechat/plugins" / source.name))
prefixes = {formula: Path(run("brew", "--prefix", formula)) for formula in (
- "gtk+3", "gdk-pixbuf", "glib", "glib-networking", "librsvg", "enchant", "adwaita-icon-theme", "hicolor-icon-theme", "gsettings-desktop-schemas", "ca-certificates", "openssl@3", "lua", "libarchive", "gtk-mac-integration")}
+ "gtk+3", "gdk-pixbuf", "glib", "glib-networking", "librsvg", "enchant", "adwaita-icon-theme", "hicolor-icon-theme", "gsettings-desktop-schemas", "ca-certificates", "openssl@3", "lua", "libarchive", "gtk-mac-integration", "fontconfig")}
# Runtime-loaded code is not visible in the main executable's otool output.
module_groups = [
(prefixes["gdk-pixbuf"] / "lib/gdk-pixbuf-2.0/2.10.0/loaders", "lib/gdk-pixbuf-2.0/2.10.0/loaders"),
@@ -241,15 +265,7 @@ def package(stage: Path, app: Path, build_number: str) -> None:
data = data.replace(str(source), "@ZOITECHAT_RESOURCES@/" + str(target.relative_to(resources)))
(resources / cache).write_text(data + "\n")
# The private font map loads the bundled Noto font; ordinary GTK fonts stay local.
- (resources / "fonts.conf").write_text('''
-
-
- /System/Library/Fonts/Library/Fonts~/Library/Fonts
- fontconfig
- sans-serifHelvetica
- monospaceMenlo
-
-''')
+ write_fontconfig(resources, prefixes["fontconfig"])
(resources / "zoitechat-bundle").write_text("1\n")
licenses = resources / "licenses"
licenses.mkdir()
diff --git a/packaging/macos/tests/test_bundle.py b/packaging/macos/tests/test_bundle.py
index a01ee2d0..c04aaa00 100644
--- a/packaging/macos/tests/test_bundle.py
+++ b/packaging/macos/tests/test_bundle.py
@@ -1,16 +1,63 @@
"""Dependency parsing, relocation guards and release audit tests without a Mac SDK."""
from pathlib import Path
+import os
import shutil
+import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
+from xml.sax.saxutils import escape
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import bundle
class BundleTests(unittest.TestCase):
+ def test_missing_bitmap_scaling_rules_fail_packaging(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ with self.assertRaisesRegex(RuntimeError, "bitmap scaling rules not found"):
+ bundle.write_fontconfig(directory / "Resources", directory / "missing-prefix")
+
+ @unittest.skipUnless(shutil.which("fc-match"), "requires Fontconfig's matching tool")
+ def test_bundled_bitmap_scaling_survives_relocation(self):
+ prefix = Path(bundle.run("brew", "--prefix", "fontconfig")) if sys.platform == "darwin" else Path("/usr")
+ rule = prefix / "share/fontconfig/conf.avail/10-scale-bitmap-fonts.conf"
+ if not rule.is_file():
+ self.skipTest("Fontconfig bitmap scaling configuration unavailable")
+ font = bundle.ROOT / "data/fonts/NotoColorEmoji.ttf"
+ with tempfile.TemporaryDirectory(prefix="zoitechat font scaling ") as temporary:
+ directory = Path(temporary)
+ source = directory / "build-prefix/share/fontconfig/conf.avail" / rule.name
+ source.parent.mkdir(parents=True)
+ shutil.copy2(rule, source)
+ resources = directory / "Resources"
+ bundle.write_fontconfig(resources, directory / "build-prefix")
+ relocated = directory / "Relocated Resources"
+ shutil.move(resources, relocated)
+ source.unlink()
+ # Exercise the shipped configuration without consulting host font
+ # rules. Only the bundled font is visible after reset-dirs.
+ wrapper = directory / "probe.conf"
+ wrapper.write_text(
+ '' + escape(str(relocated / "fonts.conf")) +
+ '' + escape(str(font.parent)) + '')
+ env = os.environ.copy()
+ env["FONTCONFIG_FILE"] = str(wrapper)
+ env["XDG_CACHE_HOME"] = str(directory / "cache")
+ for requested in (12, 16, 24):
+ output = subprocess.check_output(
+ ["fc-match", "-f", "%{pixelsize}\\n%{matrix}\\n%{file}\\n",
+ f"Noto Color Emoji:pixelsize={requested}"], env=env, cwd=directory, text=True)
+ pixels, matrix, matched = output.splitlines()
+ self.assertEqual(Path(matched).resolve(), font.resolve())
+ values = [float(value) for value in matrix.split()]
+ self.assertEqual(len(values), 4, "bitmap scaling matrix missing")
+ self.assertAlmostEqual(float(pixels) * values[0], requested, places=3)
+ self.assertAlmostEqual(float(pixels) * values[3], requested, places=3)
+ self.assertEqual(values[1:3], [0.0, 0.0])
+
@unittest.skipUnless(sys.platform == "darwin", "requires Apple's Mach-O tools")
def test_signed_binary_relocation_and_resigning(self):
with tempfile.TemporaryDirectory(prefix="zoitechat relocation ") as temporary: