diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1f949372f5bd..14384e85f35a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -113,6 +113,7 @@ import { persistServerRuntimeState, } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; +import { systemFontsHttpApiLayer } from "./systemFonts.http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; @@ -449,6 +450,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(orchestrationHttpApiLayer), Layer.provide(pullRequestHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), + Layer.provide(systemFontsHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), otlpTracesProxyRouteLayer, diff --git a/apps/server/src/systemFonts.http.ts b/apps/server/src/systemFonts.http.ts new file mode 100644 index 000000000000..3f8d911e20cb --- /dev/null +++ b/apps/server/src/systemFonts.http.ts @@ -0,0 +1,47 @@ +import { AuthOrchestrationReadScope, EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { + annotateEnvironmentRequest, + failEnvironmentNotFound, + requireEnvironmentScope, +} from "./auth/http.ts"; +import * as ProcessRunner from "./processRunner.ts"; +import { enumerateHostFontFamilies, readHostFontFamily } from "./systemFonts.ts"; + +export const systemFontsHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "system", + (handlers) => + Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; + const fileSystem = yield* FileSystem.FileSystem; + return handlers + .handle( + "fonts", + Effect.fn("environment.system.fonts")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + return yield* enumerateHostFontFamilies().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ); + }), + ) + .handle( + "fontFile", + Effect.fn("environment.system.fontFile")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + const bytes = yield* readHostFontFamily(args.payload.family).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + if (bytes === null) return yield* failEnvironmentNotFound("font_not_found"); + return bytes; + }), + ); + }), +).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/systemFonts.test.ts b/apps/server/src/systemFonts.test.ts new file mode 100644 index 000000000000..782ae7dca633 --- /dev/null +++ b/apps/server/src/systemFonts.test.ts @@ -0,0 +1,118 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ProcessRunner, type ProcessRunInput } from "./processRunner.ts"; +import { + enumerateHostFontFamilies, + parseFontconfigFamilies, + parseFontconfigMatchFile, + parseMacFontFile, + parseMacFontFamilies, + parseWindowsFontFamilies, + readHostFontFamily, +} from "./systemFonts.ts"; + +describe("host font enumeration", () => { + it("normalizes fontconfig families and aliases", () => { + expect(parseFontconfigFamilies("Jost*,Jost* Black\nInter\nJost*\n")).toEqual([ + "Inter", + "Jost*", + "Jost* Black", + ]); + }); + + it("parses platform JSON output", () => { + expect(parseWindowsFontFamilies('["Inter","Jost*","Inter"]')).toEqual(["Inter", "Jost*"]); + expect( + parseMacFontFamilies( + JSON.stringify({ SPFontsDataType: [{ family: "Jost*" }, { familyName: "Inter" }] }), + ), + ).toEqual(["Inter", "Jost*"]); + }); + + it("resolves exact font files without accepting a fontconfig fallback", () => { + expect(parseFontconfigMatchFile("Jost*\u001f/fonts/Jost.ttf", "Jost*")).toBe("/fonts/Jost.ttf"); + expect(parseFontconfigMatchFile("Noto Sans\u001f/fonts/Noto.ttf", "Missing Font")).toBeNull(); + expect( + parseMacFontFile( + JSON.stringify({ + SPFontsDataType: [{ family: "Jost*", path: "/Library/Fonts/Jost.ttf" }], + }), + "Jost*", + ), + ).toBe("/Library/Fonts/Jost.ttf"); + }); + + it.effect("queries fontconfig on every invocation", () => { + let calls = 0; + let lastInput: ProcessRunInput | null = null; + const runner = ProcessRunner.of({ + run: (input) => { + calls += 1; + lastInput = input; + return Effect.succeed({ + code: ChildProcessSpawner.ExitCode(0), + stdout: calls === 1 ? "Inter\n" : "Inter\nJost*\n", + stderr: "", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + }, + }); + + return Effect.gen(function* () { + expect(yield* enumerateHostFontFamilies()).toEqual({ + families: ["Inter"], + status: "available", + }); + expect(yield* enumerateHostFontFamilies()).toEqual({ + families: ["Inter", "Jost*"], + status: "available", + }); + expect(lastInput).toMatchObject({ + command: "fc-list", + args: ["--format=%{family}\\n"], + }); + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(ProcessRunner, runner), + ); + }); + + it.effect("reads the exact fontconfig match as binary data", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-font-test-" }); + const fontPath = path.join(directory, "Jost.ttf"); + yield* fileSystem.writeFile(fontPath, Uint8Array.from([1, 2, 3, 4])); + const runner = ProcessRunner.of({ + run: () => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(0), + stdout: `Jost*\u001f${fontPath}`, + stderr: "", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }); + + const bytes = yield* readHostFontFamily("Jost*").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(ProcessRunner, runner), + ); + expect(bytes === null ? null : [...bytes]).toEqual([1, 2, 3, 4]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/systemFonts.ts b/apps/server/src/systemFonts.ts new file mode 100644 index 000000000000..4c9b6a218026 --- /dev/null +++ b/apps/server/src/systemFonts.ts @@ -0,0 +1,243 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { ProcessRunner } from "./processRunner.ts"; + +export interface HostFontEnumerationResult { + readonly families: readonly string[]; + readonly status: "available" | "unsupported"; +} + +class HostFontOutputParseError extends Data.TaggedError("HostFontOutputParseError")<{ + readonly cause: unknown; +}> {} + +const MAX_FONT_FILE_BYTES = 32 * 1024 * 1024; + +function sortedFamilies(families: Iterable): readonly string[] { + return [...new Set([...families].map((family) => family.trim()).filter(Boolean))].sort((a, b) => + a.localeCompare(b), + ); +} + +export function parseFontconfigFamilies(output: string): readonly string[] { + return sortedFamilies(output.split(/\r?\n/).flatMap((line) => line.split(","))); +} + +export function parseWindowsFontFamilies(output: string): readonly string[] { + const parsed: unknown = JSON.parse(output); + if (typeof parsed === "string") return sortedFamilies([parsed]); + if (!Array.isArray(parsed)) return []; + return sortedFamilies(parsed.filter((family): family is string => typeof family === "string")); +} + +export function parseMacFontFamilies(output: string): readonly string[] { + const parsed: unknown = JSON.parse(output); + const families: string[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const child of value) visit(child); + return; + } + if (value === null || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + if ((key === "family" || key === "familyName") && typeof child === "string") { + families.push(child); + } + visit(child); + } + }; + visit(parsed); + return sortedFamilies(families); +} + +export function parseFontconfigMatchFile(output: string, family: string): string | null { + const [familyOutput, fileOutput] = output.split("\u001f", 2); + if (familyOutput === undefined || fileOutput === undefined) return null; + const matchesFamily = familyOutput + .split(",") + .some( + (candidate) => + candidate.trim().localeCompare(family, undefined, { sensitivity: "accent" }) === 0, + ); + return matchesFamily && fileOutput.trim().length > 0 ? fileOutput.trim() : null; +} + +export function parseMacFontFile(output: string, family: string): string | null { + const parsed: unknown = JSON.parse(output); + let match: string | null = null; + const visit = (value: unknown): void => { + if (match !== null) return; + if (Array.isArray(value)) { + for (const child of value) visit(child); + return; + } + if (value === null || typeof value !== "object") return; + const record = value as Record; + const candidate = record.family ?? record.familyName; + if ( + typeof candidate === "string" && + candidate.localeCompare(family, undefined, { sensitivity: "accent" }) === 0 && + typeof record.path === "string" + ) { + match = record.path; + return; + } + for (const child of Object.values(record)) visit(child); + }; + visit(parsed); + return match; +} + +const WINDOWS_FONT_COMMAND = + "[System.Reflection.Assembly]::LoadWithPartialName('System.Drawing') | Out-Null; " + + "(New-Object System.Drawing.Text.InstalledFontCollection).Families.Name | ConvertTo-Json -Compress"; + +const WINDOWS_FONT_FILE_COMMAND = ` +$family = [Console]::In.ReadToEnd() +$roots = @( + 'HKCU:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts', + 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts' +) +$matches = foreach ($root in $roots) { + if (-not (Test-Path $root)) { continue } + $key = Get-Item $root + foreach ($name in $key.GetValueNames()) { + if (-not $name.StartsWith($family, [StringComparison]::OrdinalIgnoreCase)) { continue } + $value = $key.GetValue($name) + if (-not [IO.Path]::IsPathRooted($value)) { + $value = Join-Path $env:WINDIR (Join-Path 'Fonts' $value) + } + [PSCustomObject]@{ Name = $name; Path = $value } + } +} +$match = $matches | Sort-Object { $_.Name.Length } | Select-Object -First 1 +if ($null -ne $match) { [Console]::Out.Write($match.Path) } +`; + +export const enumerateHostFontFamilies = Effect.fn("systemFonts.enumerateHostFontFamilies")( + function* () { + const platform = yield* HostProcessPlatform; + const processRunner = yield* ProcessRunner; + const command = + platform === "linux" + ? { executable: "fc-list", args: ["--format=%{family}\\n"], parse: parseFontconfigFamilies } + : platform === "darwin" + ? { + executable: "/usr/sbin/system_profiler", + args: ["SPFontsDataType", "-json", "-detailLevel", "mini"], + parse: parseMacFontFamilies, + } + : platform === "win32" + ? { + executable: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_FONT_COMMAND], + parse: parseWindowsFontFamilies, + } + : null; + if (command === null) return { families: [], status: "unsupported" } as const; + + return yield* processRunner + .run({ + command: command.executable, + args: command.args, + timeout: "15 seconds", + maxOutputBytes: 16 * 1024 * 1024, + }) + .pipe( + Effect.flatMap((result) => { + if (result.code !== 0) { + return Effect.succeed({ families: [], status: "unsupported" } as const); + } + return Effect.try({ + try: (): HostFontEnumerationResult => ({ + families: command.parse(result.stdout), + status: "available", + }), + catch: (cause) => new HostFontOutputParseError({ cause }), + }); + }), + Effect.catch((cause) => + Effect.logWarning("Failed to enumerate host fonts", { cause }).pipe( + Effect.as({ families: [], status: "unsupported" } as const), + ), + ), + ); + }, +); + +/** + * Read one exact host font face so a renderer with a stale system-font cache + * can register it as a document font. The family is resolved by the OS; it is + * never interpreted as a path supplied by the client. + */ +export const readHostFontFamily = Effect.fn("systemFonts.readHostFontFamily")(function* ( + family: string, +) { + const platform = yield* HostProcessPlatform; + const processRunner = yield* ProcessRunner; + const fileSystem = yield* FileSystem.FileSystem; + const command = + platform === "linux" + ? { + executable: "fc-match", + args: ["--format=%{family}\u001f%{file}", family], + stdin: undefined, + parse: (output: string) => parseFontconfigMatchFile(output, family), + } + : platform === "darwin" + ? { + executable: "/usr/sbin/system_profiler", + args: ["SPFontsDataType", "-json", "-detailLevel", "mini"], + stdin: undefined, + parse: (output: string) => parseMacFontFile(output, family), + } + : platform === "win32" + ? { + executable: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_FONT_FILE_COMMAND], + stdin: family, + parse: (output: string) => (output.trim().length > 0 ? output.trim() : null), + } + : null; + if (command === null) return null; + + return yield* processRunner + .run({ + command: command.executable, + args: command.args, + stdin: command.stdin, + timeout: "15 seconds", + maxOutputBytes: 16 * 1024 * 1024, + }) + .pipe( + Effect.flatMap((result) => { + if (result.code !== 0) return Effect.succeed(null); + return Effect.try({ + try: () => command.parse(result.stdout), + catch: (cause) => new HostFontOutputParseError({ cause }), + }); + }), + Effect.flatMap((fontPath) => { + if (fontPath === null || !/\.(otf|ttc|ttf|woff2?)$/i.test(fontPath)) { + return Effect.succeed(null); + } + return fileSystem + .stat(fontPath) + .pipe( + Effect.flatMap((stat) => + stat.type === "File" && stat.size <= MAX_FONT_FILE_BYTES + ? fileSystem.readFile(fontPath) + : Effect.succeed(null), + ), + ); + }), + Effect.catch((cause) => + Effect.logWarning("Failed to load host font family", { family, cause }).pipe( + Effect.as(null), + ), + ), + ); +}); diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 31a2f1d779c5..c82c908207a3 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { areFontAdvancesMonospace, @@ -9,11 +9,50 @@ import { DEFAULT_SANS_FONT_STACK, appearanceFontStack, cssFontFamilies, + queryInstalledFontFamilies, resolveDefaultFamilyLabel, resolveTerminalFontPreference, resolveTerminalFontSizePreference, } from "./appearanceFonts"; +describe("installed font discovery", () => { + it("reuses the cached list until a refresh is requested", async () => { + const queryLocalFonts = vi + .fn<() => Promise>>() + .mockResolvedValueOnce([{ family: "Inter" }]) + .mockResolvedValueOnce([{ family: "Fira Code" }, { family: "Inter" }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ family: "JetBrains Mono" }]); + vi.stubGlobal("window", { queryLocalFonts }); + + try { + await expect(queryInstalledFontFamilies()).resolves.toEqual({ + families: ["Inter"], + status: "granted", + }); + await expect(queryInstalledFontFamilies()).resolves.toEqual({ + families: ["Inter"], + status: "granted", + }); + await expect(queryInstalledFontFamilies({ refresh: true })).resolves.toEqual({ + families: ["Fira Code", "Inter"], + status: "granted", + }); + await expect(queryInstalledFontFamilies({ refresh: true })).resolves.toEqual({ + families: [], + status: "denied", + }); + await expect(queryInstalledFontFamilies()).resolves.toEqual({ + families: ["JetBrains Mono"], + status: "granted", + }); + expect(queryLocalFonts).toHaveBeenCalledTimes(4); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + describe("areFontAdvancesMonospace", () => { it("accepts a fixed advance and rejects any proportional glyph", () => { expect(areFontAdvancesMonospace([10, 10, 10, 10])).toBe(true); @@ -48,6 +87,7 @@ describe("cssFontFamilies", () => { it("quotes names that are not single CSS idents", () => { expect(cssFontFamilies("3270 Nerd Font")).toBe('"3270 Nerd Font"'); expect(cssFontFamilies("M+ 1m")).toBe('"M+ 1m"'); + expect(cssFontFamilies("Jost*")).toBe('"Jost*"'); }); }); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 6053e5fb0dd4..9eda4e3df97a 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -353,7 +353,7 @@ export interface InstalledFontFamiliesResult { /** * "unsupported" - the engine has no Local Font Access API (Safari, * Firefox); "denied" - the API exists but the user declined the permission - * prompt. Both fall back to the curated catalog. + * prompt. Both fall back to manual family-name entry. */ readonly status: "granted" | "denied" | "unsupported"; } @@ -366,8 +366,11 @@ let installedFamiliesCache: InstalledFontFamiliesResult | null = null; * local-fonts permission prompt. A denial is not cached, so reopening the * picker can ask again after the user changes the site setting. */ -export async function queryInstalledFontFamilies(): Promise { - if (installedFamiliesCache !== null) return installedFamiliesCache; +export async function queryInstalledFontFamilies(options?: { + readonly refresh?: boolean; +}): Promise { + if (options?.refresh) installedFamiliesCache = null; + if (!options?.refresh && installedFamiliesCache !== null) return installedFamiliesCache; const query = ( window as Window & { queryLocalFonts?: () => Promise>; diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index 6cc28567cb2e..9bdccf0167ca 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -1,7 +1,16 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts"; +import { + isFontFamilyAvailable, + isMonospaceFamily, + queryInstalledFontFamilies, +} from "../../appearanceFonts"; +import { + canUseHostFontEnumeration, + loadHostFontFamily, + queryHostFontFamilies, +} from "../../hostFonts"; import { Combobox, ComboboxEmpty, @@ -22,16 +31,20 @@ function supportsFontEnumeration(): boolean { } type FontEnumerationState = - | { readonly status: "unknown" } - | { readonly status: "granted"; readonly families: readonly string[] } - | { readonly status: "unavailable" }; + | { readonly status: "unknown"; readonly isLoading: boolean } + | { + readonly status: "granted"; + readonly families: readonly string[]; + readonly isLoading: boolean; + } + | { readonly status: "denied" | "unsupported"; readonly isLoading: boolean }; // Shared across every row: once one picker learns the fonts (or learns the -// permission is blocked), the others follow without re-querying — and the -// rows can swap to the plain-input control together. -let enumerationState: FontEnumerationState = supportsFontEnumeration() - ? { status: "unknown" } - : { status: "unavailable" }; +// permission is blocked), the others follow without re-querying. +let enumerationState: FontEnumerationState = + supportsFontEnumeration() || canUseHostFontEnumeration() + ? { status: "unknown", isLoading: false } + : { status: "unsupported", isLoading: false }; const enumerationListeners = new Set<() => void>(); function subscribeToEnumeration(listener: () => void): () => void { @@ -45,37 +58,63 @@ function readEnumerationState(): FontEnumerationState { let enumerationLoad: Promise | null = null; -/** Query installed fonts; call from a user gesture (the permission prompt needs one). */ -export function discoverInstalledFonts(): void { - if (enumerationState.status !== "unknown" || enumerationLoad !== null) return; - enumerationLoad = queryInstalledFontFamilies().then((result) => { - enumerationState = - result.status === "granted" - ? { status: "granted", families: result.families } - : { status: "unavailable" }; +function publishEnumerationState(state: FontEnumerationState): void { + enumerationState = state; + for (const listener of enumerationListeners) listener(); +} + +/** Query live host fonts locally, falling back to browser enumeration remotely. */ +export function discoverInstalledFonts(options?: { + readonly refresh?: boolean; + readonly hostOnly?: boolean; +}): Promise { + const refresh = options?.refresh === true; + if (enumerationLoad !== null) return enumerationLoad; + if (!refresh && enumerationState.status !== "unknown") return Promise.resolve(); + + publishEnumerationState({ ...enumerationState, isLoading: true }); + const load = (async () => { + const hostFamilies = await queryHostFontFamilies(); + if (hostFamilies !== null) { + return { families: hostFamilies, status: "granted" } as const; + } + if (options?.hostOnly) return null; + return queryInstalledFontFamilies({ refresh }); + })().then((result) => { enumerationLoad = null; - for (const listener of enumerationListeners) listener(); + if (result === null) { + publishEnumerationState({ status: "unknown", isLoading: false }); + return; + } + publishEnumerationState( + result.status === "granted" + ? { status: "granted", families: result.families, isLoading: false } + : { status: result.status, isLoading: false }, + ); }); + enumerationLoad = load; + return load; } let grantedProbeStarted = false; /** - * Discover eagerly when the permission is already granted, so the picker - * renders without waiting for a focus. Electron's default permission handler - * approves silently (it has no prompt UI), and a browser that granted once - * reports "granted" on later visits — in both, no user gesture is needed. - * "prompt" and "denied" states change nothing: the focus-driven flow stays, - * because raising the browser prompt still requires a gesture. + * Discover host fonts eagerly for a local T3 instance. Remote web clients use + * browser-local enumeration instead, and query eagerly only when that browser + * permission was already granted. */ function probeAlreadyGrantedPermission(): void { if (grantedProbeStarted || enumerationState.status !== "unknown") return; grantedProbeStarted = true; + if (canUseHostFontEnumeration()) { + void discoverInstalledFonts({ refresh: true, hostOnly: true }); + return; + } const permissions = typeof navigator !== "undefined" ? navigator.permissions : undefined; if (typeof permissions?.query !== "function") return; permissions.query({ name: "local-fonts" as PermissionName }).then( (status) => { - if (status.state === "granted") discoverInstalledFonts(); + if (status.state === "granted") void discoverInstalledFonts(); }, () => { // The engine does not recognize the permission name; keep the @@ -86,10 +125,10 @@ function probeAlreadyGrantedPermission(): void { /** * Whether the engine can list installed fonts (Local Font Access API — - * Chromium and Electron). "unknown" until discovery resolves the permission; - * rows render a plain family-name input until the state is known granted, - * then upgrade to the picker. Where the permission is already granted, - * discovery starts at mount and the picker appears without a focus. + * Chromium and Electron). "unknown" until discovery resolves the permission. + * The picker remains usable for exact-name entry in denied and unsupported + * environments; only browsing the complete installed list depends on this + * state. Where permission is already granted, discovery starts at mount. */ export function useFontEnumeration(): FontEnumerationState { useEffect(probeAlreadyGrantedPermission, []); @@ -106,7 +145,6 @@ export function FontFamilyPicker({ defaultFamily, selectedFamily, requireMonospace = false, - initialOpen = false, onSelect, }: { ariaLabel: string; @@ -115,27 +153,21 @@ export function FontFamilyPicker({ /** Committed family name; empty string means the default is in use. */ selectedFamily: string; requireMonospace?: boolean; - /** Open the popup on mount — set when the control upgrades under focus. */ - initialOpen?: boolean; onSelect: (family: string) => void; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); - // Open after mount rather than mounting open: a popup that first renders in - // its open state never receives Base UI's entrance style baseline, so the - // exit transition on close has no style delta, never fires transitionend, - // and the popup lingers on screen forever. - useEffect(() => { - if (initialOpen) setOpen(true); - // The prop is only meaningful at mount - the control just swapped in - // under an active focus - so later changes are deliberately ignored. - }, []); const listRef = useRef(null); const enumeration = useFontEnumeration(); const handleOpenChange = (nextOpen: boolean) => { setOpen(nextOpen); - if (nextOpen) setQuery(""); + if (nextOpen) { + setQuery(""); + // Host enumeration is deliberately uncached so installing a font while + // T3 is open is reflected the next time any picker opens. + void discoverInstalledFonts({ refresh: true }); + } }; const families = useMemo(() => { @@ -143,27 +175,45 @@ export function FontFamilyPicker({ return requireMonospace ? enumeration.families.filter(isMonospaceFamily) : enumeration.families; }, [enumeration, requireMonospace]); + const manualFamily = useMemo(() => { + const candidate = query.trim(); + if (candidate.length === 0) return null; + if ( + families.some( + (family) => family.localeCompare(candidate, undefined, { sensitivity: "accent" }) === 0, + ) + ) { + return null; + } + if (!isFontFamilyAvailable(candidate)) return null; + if (requireMonospace && !isMonospaceFamily(candidate)) return null; + return candidate; + }, [families, query, requireMonospace]); + const items = useMemo(() => { - const trimmedQuery = query.trim().toLowerCase(); + const normalizedQuery = query.trim().toLowerCase(); const result: string[] = []; - if (trimmedQuery.length === 0) result.push(DEFAULT_FONT_VALUE); + if (normalizedQuery.length === 0) result.push(DEFAULT_FONT_VALUE); + if (manualFamily !== null) result.push(manualFamily); result.push( ...families.filter( - (family) => trimmedQuery.length === 0 || family.toLowerCase().includes(trimmedQuery), + (family) => normalizedQuery.length === 0 || family.toLowerCase().includes(normalizedQuery), ), ); return result; - }, [query, families]); + }, [families, manualFamily, query]); const selectedValue = selectedFamily.length === 0 ? DEFAULT_FONT_VALUE : selectedFamily; const handlePick = (value: string) => { setOpen(false); + if (value !== DEFAULT_FONT_VALUE) void loadHostFontFamily(value); onSelect(value === DEFAULT_FONT_VALUE ? "" : value); }; const renderItem = (item: string, index: number) => { const isDefault = item === DEFAULT_FONT_VALUE; + const isManual = item === manualFamily; const family = isDefault ? defaultFamily : item; return ( @@ -175,6 +225,9 @@ export function FontFamilyPicker({ {isDefault ? ( default ) : null} + {isManual ? ( + use exact name + ) : null} {item === selectedValue ? ( ) : null} @@ -222,7 +275,7 @@ export function FontFamilyPicker({ setQuery(event.target.value)} /> + {enumeration.status !== "granted" ? ( +

+ {enumeration.status === "denied" + ? "Installed font list access is blocked. Enter an exact family name or update the browser permission." + : enumeration.status === "unsupported" + ? "This browser cannot list installed fonts. Enter an exact family name." + : enumeration.isLoading + ? "Loading installed fonts…" + : "Open the picker to load installed fonts, or enter an exact family name."} +

+ ) : null}
No fonts found. diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e4cfbe9ac033..7a0734ff0609 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -96,15 +96,13 @@ import { Input } from "../ui/input"; import { DEFAULT_CODE_FONT_STACK, DEFAULT_SANS_FONT_STACK, - isFontFamilyAvailable, - isMonospaceFamily, resolveDefaultFamilyLabel, resolveTerminalFontPreference, resolveTerminalFontSizePreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../../appearanceFonts"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; -import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; +import { FontFamilyPicker } from "./FontFamilyPicker"; import { NumberField, NumberFieldDecrement, @@ -1427,127 +1425,24 @@ function FontFamilySettingsRow({ }; }) { const trimmed = value.trim(); - // The fallback input edits a draft; the preference only commits once typing - // pauses and the text probes as an available font (or is an explicit - // clear), so the current font holds and nothing reflows mid-word. - const [draft, setDraft] = useState(value); - const [draftSettled, setDraftSettled] = useState(true); - const commitTimerRef = useRef(null); - const lastValueRef = useRef(value); - if (lastValueRef.current !== value) { - // The committed value changed externally (hydration, reset, picker - // selection); adopt it and drop any pending commit of a stale draft. - lastValueRef.current = value; - if (commitTimerRef.current !== null) { - window.clearTimeout(commitTimerRef.current); - commitTimerRef.current = null; - } - setDraft(value); - setDraftSettled(true); - } - useEffect( - () => () => { - if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current); - }, - [], - ); - const acceptsFamily = (candidate: string) => - isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate)); - const commitDraft = (next: string) => { - setDraftSettled(true); - // A rejected name stays in the field, flagged: the terminal would silently - // fall back to its default, so the row must not claim it took the value. - if (next.trim().length === 0 || acceptsFamily(next)) { - onValueChange(next); - } - }; - const flushDraft = () => { - if (commitTimerRef.current === null) return; - window.clearTimeout(commitTimerRef.current); - commitTimerRef.current = null; - commitDraft(draft); - }; - const draftTrimmed = draft.trim(); - // Flag an unknown name only once typing pauses, and never for an empty - // field - that is the starting state, not a rejected entry. - const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed; const resetToDefault = () => { - if (commitTimerRef.current !== null) { - window.clearTimeout(commitTimerRef.current); - commitTimerRef.current = null; - } - setDraft(defaultValue); - setDraftSettled(true); onReset(); }; const resetAction = value !== defaultValue || size.value !== size.defaultValue ? ( ) : null; - const fontEnumeration = useFontEnumeration(); - // Everyone starts on the plain input; focusing it is the user gesture that - // runs font discovery. Where the engine can enumerate, the control then - // upgrades to the picker - popped open when the swap happens under focus, - // so the interaction continues without a second click. - const inputFocusedRef = useRef(false); - const familyControl = - fontEnumeration.status === "granted" ? ( - - ) : ( - { - inputFocusedRef.current = true; - discoverInstalledFonts(); - }} - onBlur={() => { - inputFocusedRef.current = false; - flushDraft(); - }} - onChange={(event) => { - const next = event.currentTarget.value; - setDraft(next); - setDraftSettled(false); - if (commitTimerRef.current !== null) { - window.clearTimeout(commitTimerRef.current); - } - commitTimerRef.current = window.setTimeout(() => { - commitTimerRef.current = null; - commitDraft(next); - }, 400); - }} - onKeyDown={(event) => { - if (event.key === "Enter") flushDraft(); - if (event.key === "Escape") { - // Discard uncommitted typing without closing the settings page, - // which is what an unhandled Escape does. - event.preventDefault(); - event.stopPropagation(); - if (commitTimerRef.current !== null) { - window.clearTimeout(commitTimerRef.current); - commitTimerRef.current = null; - } - setDraft(value); - setDraftSettled(true); - } - }} - placeholder={defaultFamily} - spellCheck={false} - value={draft} - /> - ); + // The picker always supports exact-name entry. Permission only controls + // whether the browser supplies the complete installed-family list. + const familyControl = ( + + ); const control = (
{familyControl}
diff --git a/apps/web/src/hostFonts.ts b/apps/web/src/hostFonts.ts new file mode 100644 index 000000000000..011e21305c7b --- /dev/null +++ b/apps/web/src/hostFonts.ts @@ -0,0 +1,71 @@ +import { PrimaryEnvironmentHttpClient } from "./environments/primary/httpClient"; +import { runPrimaryHttp } from "./lib/runtime"; +import * as Effect from "effect/Effect"; +import { cssFontFamilies } from "./appearanceFonts"; + +const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); + +export function canUseHostFontEnumeration(): boolean { + if (typeof window === "undefined") return false; + if (window.desktopBridge !== undefined) return true; + return LOOPBACK_HOSTNAMES.has(window.location.hostname.toLowerCase()); +} + +/** + * Chromium snapshots its Local Font Access result for the life of the browser + * process. A local T3 server can enumerate the same host live after that + * snapshot goes stale. Never use this for remote web clients: their fonts live + * on the viewing device, not the environment server. + */ +export async function queryHostFontFamilies(): Promise { + if (!canUseHostFontEnumeration()) return null; + try { + const result = await runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => client.system.fonts({ headers: {} })), + ), + ); + return result.status === "available" ? result.families : null; + } catch { + return null; + } +} + +const hostFontLoads = new Map>(); + +/** Register a host font as a document font, bypassing Chromium's stale OS cache. */ +export function loadHostFontFamily(family: string): Promise { + const normalized = family.trim(); + const cssFamily = cssFontFamilies(normalized); + if ( + cssFamily === null || + !canUseHostFontEnumeration() || + typeof FontFace === "undefined" || + typeof document === "undefined" + ) { + return Promise.resolve(false); + } + const cached = hostFontLoads.get(normalized); + if (cached !== undefined) return cached; + + const load = (async () => { + try { + const bytes = await runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap((client) => + client.system.fontFile({ headers: {}, payload: { family: normalized } }), + ), + ), + ); + const face = new FontFace(cssFamily, Uint8Array.from(bytes)); + document.fonts.add(face); + await face.load(); + return true; + } catch { + hostFontLoads.delete(normalized); + return false; + } + })(); + hostFontLoads.set(normalized, load); + return load; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c4f65564efc7..bc8f8507b814 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -29,7 +29,8 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { applyAppearanceFontVariables } from "~/appearanceFonts"; +import { applyAppearanceFontVariables, isFontFamilyAvailable } from "~/appearanceFonts"; +import { loadHostFontFamily } from "../hostFonts"; import { useClientSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, @@ -179,6 +180,16 @@ function FontAppearanceSync() { sizeCode: fontSizeCode, smoothing: fontSmoothing, }); + for (const family of new Set([fontFamilySans, fontFamilyCode, fontFamilyComposer])) { + const normalized = family.trim(); + if ( + normalized.length > 0 && + !normalized.includes(",") && + !isFontFamilyAvailable(normalized) + ) { + void loadHostFontFamily(normalized); + } + } }, [ fontFamilyCode, fontFamilyComposer, diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index d209588b6097..43ab7c76ad8b 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -5,6 +5,7 @@ import * as HttpApi from "effect/unstable/httpapi/HttpApi"; import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware"; +import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -164,7 +165,10 @@ export class EnvironmentInternalError extends Schema.TaggedErrorClass()( @@ -377,6 +381,20 @@ export const AuthOtherClientSessionsRevokeResult = Schema.Struct({ }); export type AuthOtherClientSessionsRevokeResult = typeof AuthOtherClientSessionsRevokeResult.Type; +export const EnvironmentSystemFontsResult = Schema.Struct({ + families: Schema.Array(Schema.String), + status: Schema.Literals(["available", "unsupported"]), +}); +export type EnvironmentSystemFontsResult = typeof EnvironmentSystemFontsResult.Type; + +export const EnvironmentSystemFontFileQuery = { + family: TrimmedNonEmptyString.check(Schema.isMaxLength(200)), +}; + +export const EnvironmentSystemFontFile = Schema.Uint8Array.pipe( + HttpApiSchema.asUint8Array({ contentType: "application/octet-stream" }), +); + export class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add( HttpApiEndpoint.get("descriptor", "/.well-known/t3/environment", { success: ExecutionEnvironmentDescriptor, @@ -522,6 +540,28 @@ export class EnvironmentPullRequestsHttpApi extends HttpApiGroup.make("pullReque }).middleware(EnvironmentAuthenticatedAuth), ) {} +export class EnvironmentSystemHttpApi extends HttpApiGroup.make("system") + .add( + HttpApiEndpoint.get("fonts", "/api/system/fonts", { + headers: OptionalBearerHeaders, + success: EnvironmentSystemFontsResult, + error: [EnvironmentAuthInvalidError, EnvironmentScopeRequiredError, EnvironmentInternalError], + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("fontFile", "/api/system/font-file", { + headers: OptionalBearerHeaders, + payload: EnvironmentSystemFontFileQuery, + success: EnvironmentSystemFontFile, + error: [ + EnvironmentAuthInvalidError, + EnvironmentScopeRequiredError, + EnvironmentResourceNotFoundError, + EnvironmentInternalError, + ], + }).middleware(EnvironmentAuthenticatedAuth), + ) {} + export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") .add( HttpApiEndpoint.post("linkProof", "/api/connect/link-proof", { @@ -588,4 +628,5 @@ export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) .add(EnvironmentPullRequestsHttpApi) + .add(EnvironmentSystemHttpApi) .add(EnvironmentConnectHttpApi) {}