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: