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
223 lines
7.7 KiB
TypeScript
223 lines
7.7 KiB
TypeScript
import { TerminalFont } from "../infrastructure/config/fonts"
|
|
|
|
/**
|
|
* Type definition for Local Font Access API
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API
|
|
*/
|
|
interface LocalFontData {
|
|
family: string;
|
|
}
|
|
|
|
/**
|
|
* Known monospace font families that don't follow naming conventions.
|
|
* These are popular programming/terminal fonts that should be included.
|
|
*/
|
|
const KNOWN_MONOSPACE_FONTS = new Set([
|
|
// Popular programming fonts
|
|
'iosevka',
|
|
'hack',
|
|
'consolas',
|
|
'menlo',
|
|
'monaco',
|
|
'inconsolata',
|
|
'mononoki',
|
|
'fantasque sans mono',
|
|
'anonymous pro',
|
|
'liberation mono',
|
|
'dejavu sans mono',
|
|
'droid sans mono',
|
|
'ubuntu mono',
|
|
'roboto mono',
|
|
'source code pro',
|
|
'fira code',
|
|
'fira mono',
|
|
'jetbrains mono',
|
|
'cascadia code',
|
|
'cascadia mono',
|
|
'victor mono',
|
|
'ibm plex mono',
|
|
'sf mono',
|
|
'operator mono',
|
|
'input mono',
|
|
'pragmata pro',
|
|
'berkeley mono',
|
|
'monaspace',
|
|
'geist mono',
|
|
'comic mono',
|
|
'courier',
|
|
'courier new',
|
|
'lucida console',
|
|
'pt mono',
|
|
'overpass mono',
|
|
'space mono',
|
|
'go mono',
|
|
'noto sans mono',
|
|
'sarasa mono',
|
|
'maple mono',
|
|
'meslolgs nf',
|
|
'symbols nerd font mono',
|
|
'symbols nerd font',
|
|
]);
|
|
|
|
/**
|
|
* Suffix indicators that suggest a font is monospace
|
|
*/
|
|
const MONO_SUFFIX_INDICATORS = ['mono', 'monospace', 'code', 'terminal', 'console'];
|
|
|
|
/**
|
|
* Checks if a font family name indicates a monospace font.
|
|
* Uses both known font list and suffix matching for comprehensive detection.
|
|
*/
|
|
function isMonospaceFont(familyName: string): boolean {
|
|
const familyLower = familyName.toLowerCase().trim();
|
|
|
|
// Check against known monospace fonts (exact or partial match)
|
|
for (const knownFont of KNOWN_MONOSPACE_FONTS) {
|
|
if (familyLower === knownFont || familyLower.startsWith(knownFont + ' ')) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check suffix indicators with word boundary
|
|
return MONO_SUFFIX_INDICATORS.some(indicator => {
|
|
return (
|
|
familyLower === indicator ||
|
|
familyLower.endsWith(' ' + indicator) ||
|
|
familyLower.endsWith('-' + indicator) ||
|
|
familyLower.includes(' ' + indicator + ' ')
|
|
);
|
|
});
|
|
}
|
|
|
|
// Cached unfiltered system family list so we don't hit the Local Font
|
|
// Access API more than once per session. Populated as a side effect of
|
|
// queryAllSystemFontsOnce(), which both getMonospaceFonts() and
|
|
// fontAvailability.ts read.
|
|
let allSystemFamiliesCache: Set<string> | null = null;
|
|
let allSystemFamilyNamesCache: string[] | null = null;
|
|
|
|
// In-flight promise dedup: when fontStore.initialize() runs
|
|
// getMonospaceFonts() and getAllSystemFontFamilies() in parallel, both
|
|
// would otherwise hit queryLocalFonts() before the cache is populated,
|
|
// causing two redundant Local Font Access API calls and potential
|
|
// permission-handling races. Caching the promise itself means
|
|
// concurrent callers await the same single invocation.
|
|
let queryPromise: Promise<LocalFontData[]> | null = null;
|
|
|
|
/** Clears the cached font query so a user-initiated refresh sees changes. */
|
|
export function clearLocalFontsCache(): void {
|
|
queryPromise = null;
|
|
allSystemFamiliesCache = null;
|
|
allSystemFamilyNamesCache = null;
|
|
}
|
|
|
|
/** Test alias kept explicit so existing tests communicate their intent. */
|
|
export const __resetLocalFontsCacheForTesting = clearLocalFontsCache;
|
|
|
|
function queryAllSystemFontsOnce(): Promise<LocalFontData[]> {
|
|
if (queryPromise) return queryPromise;
|
|
queryPromise = (async () => {
|
|
if (typeof window === "undefined" || !("queryLocalFonts" in window)) {
|
|
return [];
|
|
}
|
|
try {
|
|
const queryLocalFonts = (window as unknown as {
|
|
queryLocalFonts: () => Promise<LocalFontData[]>;
|
|
}).queryLocalFonts;
|
|
const fonts = await queryLocalFonts();
|
|
// A desktop OS always has fonts. Chromium can still resolve the
|
|
// API with an empty list when access is temporarily unavailable;
|
|
// do not treat that as authoritative or cache it for the session.
|
|
if (fonts.length === 0) {
|
|
queryPromise = null;
|
|
return [];
|
|
}
|
|
const familyNamesByLower = new Map<string, string>();
|
|
for (const font of fonts) {
|
|
const family = font.family.trim();
|
|
if (!family) continue;
|
|
const normalized = family.toLowerCase();
|
|
if (!familyNamesByLower.has(normalized)) {
|
|
familyNamesByLower.set(normalized, family);
|
|
}
|
|
}
|
|
allSystemFamilyNamesCache = [...familyNamesByLower.values()].sort((a, b) =>
|
|
a.localeCompare(b, undefined, { sensitivity: 'base' }),
|
|
);
|
|
allSystemFamiliesCache = new Set(familyNamesByLower.keys());
|
|
return fonts;
|
|
} catch (error) {
|
|
// Don't sticky-cache a transient failure (e.g. LFA permission
|
|
// not ready yet at app boot, AbortError, etc.). Clearing the
|
|
// module-level promise lets the very next caller retry the
|
|
// API. Successful calls keep their cached promise as before,
|
|
// so this only retries when something actually went wrong.
|
|
console.warn('Failed to query local fonts:', error);
|
|
queryPromise = null;
|
|
return [];
|
|
}
|
|
})();
|
|
return queryPromise;
|
|
}
|
|
|
|
/**
|
|
* Returns the case-insensitive set of every font family installed on the
|
|
* system, as reported by the Local Font Access API. Used by
|
|
* fontAvailability.ts to decide which built-in font choices to show in
|
|
* the dropdown.
|
|
*
|
|
* Returns null when the API is unavailable or permission has been
|
|
* denied — callers should treat that as "no authoritative data" and
|
|
* fall back to canvas-width detection.
|
|
*/
|
|
export async function getAllSystemFontFamilies(): Promise<Set<string> | null> {
|
|
if (allSystemFamiliesCache) return allSystemFamiliesCache;
|
|
await queryAllSystemFontsOnce();
|
|
return allSystemFamiliesCache;
|
|
}
|
|
|
|
/**
|
|
* Returns installed font family names with display casing preserved.
|
|
* Families are trimmed, deduplicated case-insensitively, and sorted for
|
|
* stable searchable pickers.
|
|
*/
|
|
export async function getAllSystemFontFamilyNames(): Promise<string[] | null> {
|
|
if (allSystemFamilyNamesCache) return allSystemFamilyNamesCache;
|
|
await queryAllSystemFontsOnce();
|
|
return allSystemFamilyNamesCache;
|
|
}
|
|
|
|
/**
|
|
* Queries local monospace fonts from the system using the Font Access API.
|
|
* Returns an empty array if the API is not available or permission is denied.
|
|
*/
|
|
export async function getMonospaceFonts(): Promise<TerminalFont[]> {
|
|
const fonts = await queryAllSystemFontsOnce();
|
|
if (fonts.length === 0) return [];
|
|
|
|
// Filter monospace fonts using robust word boundary matching
|
|
const monoFonts = fonts.filter(f => isMonospaceFont(f.family));
|
|
|
|
// Deduplicate by family name, case-insensitive (API may return multiple entries per family)
|
|
const uniqueFamilies = new Set<string>();
|
|
const dedupedFonts = monoFonts.filter(f => {
|
|
const key = f.family.toLowerCase();
|
|
if (uniqueFamilies.has(key)) return false;
|
|
uniqueFamilies.add(key);
|
|
return true;
|
|
});
|
|
|
|
// Raw Latin family only; CJK fallback is composed at runtime by
|
|
// composeFontFamilyStack() in cjkFonts.ts.
|
|
return dedupedFonts.map(f => {
|
|
const quoted = /\s/.test(f.family) ? `"${f.family}"` : f.family;
|
|
return {
|
|
id: f.family,
|
|
name: f.family,
|
|
family: `${quoted}, monospace`,
|
|
description: `Local font: ${f.family}`,
|
|
category: 'monospace' as const,
|
|
};
|
|
});
|
|
}
|