Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
190 lines
7.0 KiB
TypeScript
190 lines
7.0 KiB
TypeScript
/**
|
|
* Decides whether a CSS font-family is actually rendered (system-installed
|
|
* or loaded via @font-face) on the current machine. Used to filter the
|
|
* terminal font dropdowns.
|
|
*
|
|
* Why not document.fonts.check(): in Chromium it returns true for any
|
|
* syntactically-valid family name regardless of whether that font is
|
|
* actually installed (a deliberate fingerprinting-mitigation choice), so
|
|
* it produces massive false positives. We rely instead on:
|
|
*
|
|
* 1. KNOWN_BUNDLED_FAMILIES — fonts we ship via @font-face / @fontsource.
|
|
* Always true.
|
|
* 2. setSystemFamilies() — an authoritative Set populated by fontStore
|
|
* after Local Font Access API returns. Membership lookup. When
|
|
* populated, this is the only signal needed for system fonts.
|
|
* 3. Canvas width fallback — used only before setSystemFamilies() runs
|
|
* or when the Font Access API is unavailable / denied. A font counts
|
|
* as installed only when its rendered width differs from ALL three
|
|
* generic fallbacks (serif, sans-serif, monospace).
|
|
*/
|
|
|
|
import { splitFontFamilyList } from '../infrastructure/config/cjkFonts';
|
|
|
|
const KNOWN_BUNDLED_FAMILIES = new Set<string>([
|
|
'JetBrains Mono', // @fontsource/jetbrains-mono (400, 500, 600)
|
|
'Sarasa Mono SC', // public/fonts/SarasaMonoSC-Regular.woff2 (OFL)
|
|
]);
|
|
|
|
const KNOWN_BUNDLED_BY_LOWER = new Map<string, string>(
|
|
[...KNOWN_BUNDLED_FAMILIES].map((name) => [name.toLowerCase(), name]),
|
|
);
|
|
|
|
/**
|
|
* The bundled webfonts (shipped via @font-face / @fontsource) that appear
|
|
* in a composed font-family stack, in stack order and de-duplicated, with
|
|
* their canonical casing. Used to explicitly preload the fonts a terminal
|
|
* actually renders with so xterm can remeasure the cell grid once they
|
|
* load — these faces use `font-display: swap`, so without an explicit
|
|
* preload + remeasure their late swap garbles the grid until a manual
|
|
* resize (#1647). System fonts and the generic `monospace` keyword are
|
|
* skipped.
|
|
*/
|
|
export function bundledFamiliesInStack(fontFamilyCss: string): string[] {
|
|
const seen = new Set<string>();
|
|
const result: string[] = [];
|
|
for (const token of splitFontFamilyList(fontFamilyCss)) {
|
|
const bare = token.replace(/^["']|["']$/g, '').toLowerCase();
|
|
const canonical = KNOWN_BUNDLED_BY_LOWER.get(bare);
|
|
if (!canonical || seen.has(canonical)) continue;
|
|
seen.add(canonical);
|
|
result.push(canonical);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
let systemFamilies: Set<string> | null = null;
|
|
let availabilityVersion = 0;
|
|
const listeners = new Set<() => void>();
|
|
|
|
/**
|
|
* "Fira Code", monospace → Fira Code | Menlo, monospace → Menlo.
|
|
* Quote-aware so a single family name containing commas (CSS permits
|
|
* `"Foo, Inc. Mono"`) survives intact instead of being truncated.
|
|
*/
|
|
export function extractPrimaryFamily(familyCssString: string): string {
|
|
const first = splitFontFamilyList(familyCssString)[0] ?? '';
|
|
return first.replace(/^["']|["']$/g, '');
|
|
}
|
|
|
|
/**
|
|
* Called by fontStore once Local Font Access API has returned the full
|
|
* list of installed family names (lower-cased). After this runs,
|
|
* isFontInstalled answers from this authoritative set rather than from
|
|
* canvas measurement.
|
|
*
|
|
* Notifies subscribers so React components memoizing on availability
|
|
* can recompute (e.g. dropdown filters that called isFontInstalled
|
|
* before authoritative data arrived).
|
|
*/
|
|
export function setSystemFamilies(families: Set<string> | null): void {
|
|
systemFamilies = families;
|
|
availabilityVersion += 1;
|
|
for (const listener of listeners) listener();
|
|
}
|
|
|
|
/** True when authoritative system data is available; canvas fallback skipped. */
|
|
export function hasAuthoritativeData(): boolean {
|
|
return systemFamilies !== null;
|
|
}
|
|
|
|
/**
|
|
* Subscribe to changes in font availability. Returns an unsubscribe fn.
|
|
* Used together with getFontAvailabilityVersion() and
|
|
* useSyncExternalStore in React components that filter on
|
|
* isFontInstalled() — so their useMemo dependencies invalidate when
|
|
* the authoritative install set is populated or cleared.
|
|
*/
|
|
export function subscribeFontAvailability(callback: () => void): () => void {
|
|
listeners.add(callback);
|
|
return () => {
|
|
listeners.delete(callback);
|
|
};
|
|
}
|
|
|
|
/** Monotonically increasing version, bumped on every setSystemFamilies. */
|
|
export function getFontAvailabilityVersion(): number {
|
|
return availabilityVersion;
|
|
}
|
|
|
|
const cache = new Map<string, boolean>();
|
|
|
|
interface DetectionContext {
|
|
measureText: (font: string, text: string) => number;
|
|
}
|
|
|
|
const TEST_STRING = 'mmmmmmmmmmlli';
|
|
const FALLBACK_FAMILIES = ['serif', 'sans-serif', 'monospace'] as const;
|
|
|
|
function buildBrowserContext(): DetectionContext | null {
|
|
if (typeof document === 'undefined') return null;
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return null;
|
|
return {
|
|
measureText: (font, text) => {
|
|
ctx.font = font;
|
|
return ctx.measureText(text).width;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pure detection logic — exported for testing without a DOM.
|
|
*
|
|
* Returns true if rendering the probe string against ANY of the three
|
|
* generic fallbacks (serif, sans-serif, monospace) with the target font
|
|
* listed first produces a different width than the bare generic. We use
|
|
* "some" rather than "every" because some platform defaults make a
|
|
* generic family literally identical to a real installed font — for
|
|
* example on macOS the `monospace` generic resolves to Menlo, so
|
|
* measure("'Menlo', monospace") === measure("monospace"). Requiring all
|
|
* three to differ would then falsely report Menlo as missing. A truly
|
|
* uninstalled font falls through to each generic in turn and matches
|
|
* all three, so "some" still correctly returns false for those.
|
|
*/
|
|
export function detectInstalledWithContext(
|
|
family: string,
|
|
ctx: DetectionContext,
|
|
): boolean {
|
|
if (KNOWN_BUNDLED_FAMILIES.has(family)) return true;
|
|
return FALLBACK_FAMILIES.some((fb) => {
|
|
const baseWidth = ctx.measureText(`72px ${fb}`, TEST_STRING);
|
|
const targetWidth = ctx.measureText(`72px "${family}", ${fb}`, TEST_STRING);
|
|
return baseWidth !== targetWidth;
|
|
});
|
|
}
|
|
|
|
export function isFontInstalled(family: string): boolean {
|
|
if (KNOWN_BUNDLED_FAMILIES.has(family)) return true;
|
|
|
|
// Authoritative path: Local Font Access API enumeration.
|
|
if (systemFamilies) {
|
|
return systemFamilies.has(family.toLowerCase());
|
|
}
|
|
|
|
// Fallback path: canvas measurement, cached per family. Only used
|
|
// before setSystemFamilies has run, or when the API is denied.
|
|
const cached = cache.get(family);
|
|
if (cached !== undefined) return cached;
|
|
|
|
const ctx = buildBrowserContext();
|
|
// No DOM (SSR / tests) and no authoritative data → treat as available
|
|
// so we don't aggressively hide everything.
|
|
if (!ctx) {
|
|
cache.set(family, true);
|
|
return true;
|
|
}
|
|
|
|
const result = detectInstalledWithContext(family, ctx);
|
|
cache.set(family, result);
|
|
return result;
|
|
}
|
|
|
|
export function clearFontAvailabilityCache(): void {
|
|
cache.clear();
|
|
systemFamilies = null;
|
|
availabilityVersion += 1;
|
|
for (const listener of listeners) listener();
|
|
}
|