mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 15:20:11 +02:00
feat(setup): auto-install color-emoji font on Linux
macOS and Windows ship a color-emoji font; most Linux distros/containers ship none, so make-pdf emits tofu there. ensure_emoji_font() best-effort installs fonts-noto-color-emoji (apt, with dnf/pacman/apk fallbacks) and refreshes the fontconfig cache. Hardened: Linux-only guard, GSTACK_SKIP_FONTS escape hatch, fc-match color=True detection (the broad fc-list query false-matched LastResort), sudo -n so a password prompt fails fast instead of hanging, DEBIAN_FRONTEND=noninteractive, timeout 30 on apt update, and fc-cache under sudo. Warns instead of failing. After a fresh install, refresh_browse_daemon_for_fonts() runs 'browse stop' so the next render spawns a Chromium that sees the new font (font fallback is process-cached). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -261,6 +261,82 @@ ensure_playwright_browser() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure a color-emoji font is installed (Linux only).
|
||||
#
|
||||
# Chromium renders emoji code points as .notdef "tofu" (▯) when no color-emoji
|
||||
# font is installed. macOS ships "Apple Color Emoji" and Windows ships "Segoe UI
|
||||
# Emoji", so they're fine out of the box. Most Linux distros and containers ship
|
||||
# NO color-emoji font, which is why make-pdf output shows tofu in headers/tables
|
||||
# that contain emoji. Install Noto Color Emoji to fix it.
|
||||
#
|
||||
# Best-effort: warn (don't fail) if we can't install — PDFs still generate, they
|
||||
# just fall back to tofu for emoji as before. Skip entirely with
|
||||
# GSTACK_SKIP_FONTS=1 (CI without sudo, managed machines, offline envs).
|
||||
#
|
||||
# Returns 0 and sets EMOJI_FONT_INSTALLED=1 when it actually installs a font.
|
||||
EMOJI_FONT_INSTALLED=0
|
||||
ensure_emoji_font() {
|
||||
# macOS/Windows ship a color-emoji font; nothing to do.
|
||||
[ "$(uname -s)" = "Linux" ] || return 0
|
||||
[ "${GSTACK_SKIP_FONTS:-0}" = "1" ] && return 0
|
||||
|
||||
# Idempotency: a real COLOR emoji font that resolves for an actual emoji code
|
||||
# point (U+1F600). `fc-list :lang=und-zsye` is too broad — it matches symbol
|
||||
# and last-resort fallback fonts — so we use fc-match and require color=True.
|
||||
if command -v fc-match >/dev/null 2>&1; then
|
||||
if fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' 2>/dev/null | grep -qi 'True'; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
local sudo=""
|
||||
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||
# -n: never prompt. If a password is required we fail fast into the
|
||||
# warn-not-fail path below instead of hanging a non-interactive setup.
|
||||
sudo="sudo -n"
|
||||
fi
|
||||
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
echo "Installing color-emoji font (fonts-noto-color-emoji) so make-pdf emoji render (set GSTACK_SKIP_FONTS=1 to skip)..."
|
||||
DEBIAN_FRONTEND=noninteractive timeout 30 $sudo apt-get update -qq >/dev/null 2>&1 || true
|
||||
DEBIAN_FRONTEND=noninteractive $sudo apt-get install -y -qq fonts-noto-color-emoji >/dev/null 2>&1 || return 1
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
echo "Installing color-emoji font (google-noto-color-emoji-fonts)..."
|
||||
$sudo dnf install -y google-noto-color-emoji-fonts >/dev/null 2>&1 || return 1
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
echo "Installing color-emoji font (noto-fonts-emoji)..."
|
||||
$sudo pacman -Sy --noconfirm noto-fonts-emoji >/dev/null 2>&1 || return 1
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
echo "Installing color-emoji font (font-noto-emoji)..."
|
||||
$sudo apk add --no-cache font-noto-emoji >/dev/null 2>&1 || return 1
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Refresh fontconfig cache so Chromium picks up the new font. Run under sudo
|
||||
# for the system cache dirs (unprivileged fc-cache fails on unwritable dirs).
|
||||
if command -v fc-cache >/dev/null 2>&1; then
|
||||
$sudo fc-cache -f >/dev/null 2>&1 || fc-cache -f >/dev/null 2>&1 || true
|
||||
fi
|
||||
EMOJI_FONT_INSTALLED=1
|
||||
return 0
|
||||
}
|
||||
|
||||
# After a fresh font install, stop any running browse render daemon so the next
|
||||
# make-pdf render spawns a fresh Chromium that sees the new font. Chromium
|
||||
# caches its font list at process start, so a daemon that was alive before the
|
||||
# install would keep emitting tofu. `browse stop` is the graceful API; the
|
||||
# daemon auto-respawns on the next render. Best-effort and per-project-root, so
|
||||
# we also print a note for daemons in other roots.
|
||||
refresh_browse_daemon_for_fonts() {
|
||||
[ "$EMOJI_FONT_INSTALLED" -eq 1 ] || return 0
|
||||
if [ -x "$BROWSE_BIN" ]; then
|
||||
"$BROWSE_BIN" stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
echo " Installed a color-emoji font. The next make-pdf render will show emoji."
|
||||
echo " If a gstack browser is running in another project, restart it to pick up the font."
|
||||
}
|
||||
|
||||
prepare_bun_for_windows_compile() {
|
||||
BUN_CMD="bun"
|
||||
BUN_CMD_WAS_COPIED=0
|
||||
@@ -433,6 +509,19 @@ if ! ensure_playwright_browser; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux).
|
||||
# Best-effort: warn instead of failing if it can't install.
|
||||
if ! ensure_emoji_font; then
|
||||
echo " Note: could not auto-install a color-emoji font. Emoji in make-pdf" >&2
|
||||
echo " output may render as boxes (▯). Install one manually, e.g.:" >&2
|
||||
echo " Debian/Ubuntu: sudo apt-get install fonts-noto-color-emoji" >&2
|
||||
echo " Fedora: sudo dnf install google-noto-color-emoji-fonts" >&2
|
||||
echo " Arch: sudo pacman -S noto-fonts-emoji" >&2
|
||||
echo " Alpine: sudo apk add font-noto-emoji" >&2
|
||||
else
|
||||
refresh_browse_daemon_for_fonts
|
||||
fi
|
||||
|
||||
# 3. Ensure ~/.gstack global state directory exists
|
||||
mkdir -p "$HOME/.gstack/projects"
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SETUP_SCRIPT = path.join(ROOT, 'setup');
|
||||
const SETUP_SRC = fs.readFileSync(SETUP_SCRIPT, 'utf-8');
|
||||
|
||||
// Slice out the ensure_emoji_font helper body via anchors so the test is
|
||||
// resilient to line-number drift (same pattern as setup-windows-fallback).
|
||||
function extractHelper(): string {
|
||||
const start = SETUP_SRC.indexOf('ensure_emoji_font() {');
|
||||
const end = SETUP_SRC.indexOf('\n}\n', start);
|
||||
if (start < 0 || end < 0) throw new Error('Could not locate ensure_emoji_font() in setup');
|
||||
return SETUP_SRC.slice(start, end + 2);
|
||||
}
|
||||
|
||||
describe('setup: ensure_emoji_font static invariants', () => {
|
||||
const helper = extractHelper();
|
||||
|
||||
test('helper is defined and Linux-guarded', () => {
|
||||
expect(SETUP_SRC).toContain('ensure_emoji_font() {');
|
||||
expect(helper).toContain('[ "$(uname -s)" = "Linux" ] || return 0');
|
||||
});
|
||||
|
||||
test('honors the GSTACK_SKIP_FONTS escape hatch', () => {
|
||||
expect(helper).toContain('GSTACK_SKIP_FONTS');
|
||||
});
|
||||
|
||||
test('detects an installed COLOR emoji font via fc-match (not the broad fc-list query)', () => {
|
||||
expect(helper).toContain('fc-match');
|
||||
expect(helper).toContain(':lang=und-zsye:charset=1F600');
|
||||
// Must gate on color=True so symbol / last-resort fallback fonts don't
|
||||
// false-positive and skip a needed install.
|
||||
expect(helper).toMatch(/grep -qi ['"]True['"]/);
|
||||
// The broad fc-list query that matched LastResort is NOT used for detection.
|
||||
// (Check executable lines only — the docblock may mention fc-list to explain
|
||||
// why we avoid it.)
|
||||
const codeLines = helper
|
||||
.split('\n')
|
||||
.filter((l) => !l.trim().startsWith('#'))
|
||||
.join('\n');
|
||||
expect(codeLines).not.toContain('fc-list');
|
||||
});
|
||||
|
||||
test('uses non-interactive sudo so a password prompt fails fast (no hang)', () => {
|
||||
expect(helper).toContain('sudo -n');
|
||||
});
|
||||
|
||||
test('install path is non-interactive and timeout-guarded', () => {
|
||||
expect(helper).toContain('DEBIAN_FRONTEND=noninteractive');
|
||||
expect(helper).toMatch(/timeout 30 .*apt-get update/);
|
||||
});
|
||||
|
||||
test('covers all four package managers with the correct package names', () => {
|
||||
expect(helper).toContain('apt-get install -y -qq fonts-noto-color-emoji');
|
||||
expect(helper).toContain('dnf install -y google-noto-color-emoji-fonts');
|
||||
expect(helper).toContain('pacman -Sy --noconfirm noto-fonts-emoji');
|
||||
expect(helper).toContain('apk add --no-cache font-noto-emoji');
|
||||
});
|
||||
|
||||
test('refreshes the fontconfig cache under sudo after install', () => {
|
||||
expect(helper).toMatch(/\$sudo fc-cache -f/);
|
||||
});
|
||||
|
||||
test('marks EMOJI_FONT_INSTALLED on success and warns (not fails) elsewhere', () => {
|
||||
expect(helper).toContain('EMOJI_FONT_INSTALLED=1');
|
||||
// Failure branches return 1 (caller warns) rather than `exit`.
|
||||
expect(helper).not.toContain('exit 1');
|
||||
});
|
||||
|
||||
test('refresh_browse_daemon_for_fonts stops the daemon gracefully (no broad pkill)', () => {
|
||||
const dStart = SETUP_SRC.indexOf('refresh_browse_daemon_for_fonts() {');
|
||||
const dEnd = SETUP_SRC.indexOf('\n}\n', dStart);
|
||||
expect(dStart).toBeGreaterThanOrEqual(0);
|
||||
const body = SETUP_SRC.slice(dStart, dEnd);
|
||||
expect(body).toContain('"$BROWSE_BIN" stop');
|
||||
expect(body).not.toMatch(/pkill/);
|
||||
});
|
||||
|
||||
test('the call site warns-not-fails and never aborts setup', () => {
|
||||
expect(SETUP_SRC).toContain('if ! ensure_emoji_font; then');
|
||||
expect(SETUP_SRC).toContain('refresh_browse_daemon_for_fonts');
|
||||
});
|
||||
});
|
||||
|
||||
// Behavior matrix: source the extracted helper into a temp shell with a faked
|
||||
// PATH so we exercise the real control flow without touching the host system.
|
||||
// We fake `uname` to report Linux so the guard doesn't short-circuit on the
|
||||
// macOS/Linux test runner, and fake the package managers with sentinel-touching
|
||||
// stubs so we can assert whether an install was attempted.
|
||||
describe.skipIf(process.platform === 'win32')('setup: ensure_emoji_font behavior', () => {
|
||||
function runHelper(fcMatchOutput: string): {
|
||||
exit: number;
|
||||
installInstalled: string;
|
||||
aptCalled: boolean;
|
||||
fcCacheCalled: boolean;
|
||||
stderr: string;
|
||||
} {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-emoji-'));
|
||||
try {
|
||||
const bin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
const sentinelApt = path.join(tmp, 'apt-called');
|
||||
const sentinelCache = path.join(tmp, 'fc-cache-called');
|
||||
|
||||
const stub = (name: string, body: string) => {
|
||||
const p = path.join(bin, name);
|
||||
fs.writeFileSync(p, `#!/usr/bin/env bash\n${body}\n`);
|
||||
fs.chmodSync(p, 0o755);
|
||||
};
|
||||
stub('uname', 'echo Linux');
|
||||
// fc-match prints whatever the case wants; supports the -f format arg.
|
||||
stub('fc-match', `printf '%s\\n' ${JSON.stringify(fcMatchOutput)}`);
|
||||
stub('apt-get', `touch ${JSON.stringify(sentinelApt)}; exit 0`);
|
||||
stub('fc-cache', `touch ${JSON.stringify(sentinelCache)}; exit 0`);
|
||||
stub('sudo', 'shift; "$@"'); // sudo -n <cmd> → run <cmd> directly
|
||||
stub('command', ''); // never used; `command -v` is a builtin
|
||||
stub('timeout', 'shift; "$@"'); // timeout 30 <cmd> → run <cmd>
|
||||
stub('id', 'echo 1000'); // non-root so the sudo branch is taken
|
||||
|
||||
const helper = extractHelper();
|
||||
const script = [
|
||||
'set -e',
|
||||
'EMOJI_FONT_INSTALLED=0',
|
||||
helper,
|
||||
'ensure_emoji_font; rc=$?',
|
||||
'echo "EXIT=$rc"',
|
||||
'echo "INSTALLED=$EMOJI_FONT_INSTALLED"',
|
||||
].join('\n');
|
||||
|
||||
const result = spawnSync('bash', ['-c', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
|
||||
});
|
||||
const out = result.stdout ?? '';
|
||||
return {
|
||||
exit: Number((out.match(/EXIT=(\d+)/) ?? [])[1] ?? -1),
|
||||
installInstalled: (out.match(/INSTALLED=(\d+)/) ?? [])[1] ?? '?',
|
||||
aptCalled: fs.existsSync(sentinelApt),
|
||||
fcCacheCalled: fs.existsSync(sentinelCache),
|
||||
stderr: result.stderr ?? '',
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('short-circuits when a color emoji font already resolves (no install)', () => {
|
||||
const r = runHelper('Noto Color Emoji\tTrue');
|
||||
expect(r.exit).toBe(0);
|
||||
expect(r.aptCalled).toBe(false);
|
||||
expect(r.installInstalled).toBe('0');
|
||||
});
|
||||
|
||||
test('installs when only a non-color fallback resolves (color=False)', () => {
|
||||
const r = runHelper('LastResort\tFalse');
|
||||
expect(r.exit).toBe(0);
|
||||
expect(r.aptCalled).toBe(true);
|
||||
expect(r.fcCacheCalled).toBe(true);
|
||||
expect(r.installInstalled).toBe('1');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user