Files
NetMesh/lib/localFonts.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

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,
};
});
}