fix(fonts): render unavailable macOS user fonts through binary IPC (#643)

On macOS versions where WKWebView refuses user-installed fonts by family name (fonts in ~/Library/Fonts or /Library/Fonts), the editor, terminal, Markdown and UI fell back to the default font and Nerd Font icons showed as boxes; only a webfont loaded from the file's bytes renders.

Root cause: the shared font resolver only ever referred to fonts by local family name, so every consumer depended on the WebView resolving user fonts by name. A URL-based webfont is blocked by the existing font-src policy, which stays unchanged.

Fix: the shared resolver probes whether a family renders by name (on a document-attached canvas; a detached canvas falls back even when the font works) and only when it does not, fetches the enumerated user font files over a raw-bytes IPC (user fonts only, looked up by family, no paths accepted) and registers them with the FontFace API under a stable alias. Only the faces nearest regular/bold and their italics are loaded, once per family. Covers editor and editor fallback, diff/merge views, terminal font, fallback and built-in Nerd fallback (re-measured after loading), embedded terminal, settings preview, custom UI font, Markdown and Mermaid. A debug-only environment switch reproduces the WebView restriction for testing.

Verified on macOS 26 with the restriction simulated (icons boxes before, correct after) and without it (no font bytes transferred, no regression). Not run on the reporter's macOS version, Windows or Linux; other platforms keep the previous behaviour.

Fixes #592
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 12:23:08 +08:00
1 джерело 7a58d9f253
коміт 1e898367a5
14 files changed
+544 -22

No files matched your search

+18
Переглянути файл
@@ -18,8 +18,26 @@ it("probes the legacy list and classifies measured mono and proportional familie
]);
});
it("returns no inferred families when Canvas is absent", () => {
const remove = vi.spyOn(HTMLCanvasElement.prototype, "remove");
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
expect(detectCandidateFonts()).toEqual([]);
expect(remove).toHaveBeenCalledOnce();
});
it("detects user-installed candidates with a connected probe and removes it afterwards", () => {
const probes: HTMLCanvasElement[] = [];
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (this: HTMLCanvasElement) {
probes.push(this);
const isConnected = () => this.isConnected;
return {
font: "",
measureText(this: { font: string }) {
return { width: isConnected() && this.font.includes('"Fira Code"') ? 120 : 100 };
},
} as unknown as CanvasRenderingContext2D;
});
expect(detectCandidateFonts()).toEqual([{ name: "Fira Code", monospaced: true }]);
expect(probes[0]?.hidden).toBe(true);
expect(probes[0]?.isConnected).toBe(false);
});
it("contains failures from Canvas and never claims candidates are installed", () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
+9 -5
Переглянути файл
@@ -3,7 +3,7 @@
* can miss installed families and are only hints; they say nothing about coverage.
*/
import type { FontFamily } from "@/bindings";
import { monoFontFamily } from "./fonts";
import { createFontProbeCanvas, quoteFontFamily } from "./fonts";
// Keep the former editor's 31 candidates for recovery, not as an installed-font whitelist.
const CANDIDATES = [
@@ -41,9 +41,11 @@ const CANDIDATES = [
];
export function detectCandidateFonts(): FontFamily[] {
let canvas: HTMLCanvasElement | undefined;
try {
if (typeof document === "undefined") return [];
const ctx = document.createElement("canvas").getContext("2d");
canvas = createFontProbeCanvas();
const ctx = canvas.getContext("2d");
if (!ctx) return [];
const width = (font: string, text: string) => {
ctx.font = `32px ${font}`;
@@ -53,13 +55,13 @@ export function detectCandidateFonts(): FontFamily[] {
const out: FontFamily[] = [];
for (const name of CANDIDATES) {
const present = ["monospace", "serif", "sans-serif"].some((base) => {
const measured = width(monoFontFamily(name, "", base), sample);
const measured = width(`${quoteFontFamily(name)}, ${base}`, sample);
const baseline = width(base, sample);
return Number.isFinite(measured) && Number.isFinite(baseline) && measured !== baseline;
});
if (!present) continue;
const narrow = width(monoFontFamily(name, "", "monospace"), "iiiiiiiiii");
const wide = width(monoFontFamily(name, "", "monospace"), "WWWWWWWWWW");
const narrow = width(`${quoteFontFamily(name)}, monospace`, "iiiiiiiiii");
const wide = width(`${quoteFontFamily(name)}, monospace`, "WWWWWWWWWW");
out.push({
name,
monospaced: Number.isFinite(narrow) && Number.isFinite(wide) && Math.abs(narrow - wide) < 0.5,
@@ -70,5 +72,7 @@ export function detectCandidateFonts(): FontFamily[] {
// Canvas can be absent or disabled in browser development / hardened webviews.
// Never mark unmeasurable candidates as installed. The service adds bundled faces.
return [];
} finally {
canvas?.remove();
}
}
+129 -1
Переглянути файл
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { monoFontFamily, quoteFontFamily, uiFontFamily } from "./fonts";
const BASE = "var(--font-mono)";
@@ -43,3 +43,131 @@ describe("uiFontFamily", () => {
expect(uiFontFamily(family)).toBe(expected);
});
});
describe("macOS user font resolver", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.resetModules();
});
async function resolver(available: boolean | "symbols" | "connected", os = "macos") {
vi.resetModules();
const invoke = vi.fn();
vi.doMock("@tauri-apps/api/core", () => ({ isTauri: () => true, invoke }));
vi.doMock("./platformInfo", () => ({ guessOs: () => os }));
const canvases: HTMLCanvasElement[] = [];
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (this: HTMLCanvasElement) {
const isConnected = () => this.isConnected;
canvases.push(this);
return {
font: "",
measureText(this: { font: string }, text: string) {
const covered =
available === true ||
(available === "symbols" && text.includes("\uF418")) ||
(available === "connected" && isConnected());
return { width: covered && this.font.includes('"') ? 200 : 100 };
},
} as unknown as CanvasRenderingContext2D;
});
const registered: { family: string; descriptors: unknown }[] = [];
vi.stubGlobal(
"FontFace",
class {
constructor(
public family: string,
_bytes: ArrayBuffer,
public descriptors: unknown,
) {
registered.push(this);
}
load() {
return Promise.resolve(this);
}
},
);
const load = vi.fn().mockResolvedValue([]);
Object.defineProperty(document, "fonts", { configurable: true, value: { add: vi.fn(), delete: vi.fn(), load } });
const header = new TextEncoder().encode(
JSON.stringify([
{ weight: 400, style: "normal", offset: 0, length: 2 },
{ weight: 700, style: "normal", offset: 2, length: 2 },
{ weight: 400, style: "italic", offset: 4, length: 2 },
]),
);
const bundle = new Uint8Array(4 + header.length + 6);
new DataView(bundle.buffer).setUint32(0, header.length, true);
bundle.set(header, 4);
invoke.mockResolvedValue(bundle.buffer);
return { fonts: await import("./fonts"), invoke, registered, load, canvases };
}
it("loads an unavailable family once across primary, fallback and UI, including regular/bold/italic", async () => {
const { fonts, invoke, registered, load } = await resolver(false);
const family = fonts.monoFontFamily("JetBrainsMono Nerd Font Mono");
expect(family).toContain('"OpenIDEA User Font 0", "JetBrainsMono Nerd Font Mono"');
fonts.uiFontFamily("jetbrainsmono nerd font mono");
fonts.monoFontFamily("Inter", "JetBrainsMono Nerd Font Mono");
await fonts.fontsLoaded(family, 14);
expect(invoke).toHaveBeenCalledExactlyOnceWith("user_font_data", { family: "JetBrainsMono Nerd Font Mono" });
expect(registered.map((f) => f.descriptors)).toEqual([
{ weight: "400", style: "normal" },
{ weight: "700", style: "normal" },
{ weight: "400", style: "italic" },
]);
expect(load).toHaveBeenCalledTimes(2);
});
it("uses available local/system families without reading font data", async () => {
const { fonts, invoke } = await resolver(true);
expect(fonts.uiFontFamily("Menlo")).toBe('"Menlo", system-ui, sans-serif');
fonts.monoFontFamily("Maple Mono NF CN");
expect(invoke).not.toHaveBeenCalled();
});
it("detects user fonts that WKWebView exposes only to connected canvases without loading bytes", async () => {
const { fonts, invoke, canvases } = await resolver("connected");
expect(fonts.fontAvailable("Maple Mono NF CN")).toBe(true);
expect(fonts.familyFaces("Maple Mono NF CN")).toEqual(['"Maple Mono NF CN"']);
expect(fonts.familyFaces("JetBrainsMono Nerd Font Mono")).toEqual(['"JetBrainsMono Nerd Font Mono"']);
expect(invoke).not.toHaveBeenCalled();
expect(canvases.length).toBeGreaterThan(0);
expect(canvases.every((canvas) => canvas.hidden && !canvas.isConnected)).toBe(true);
});
it("removes the probe when a font is unavailable or canvas measurement fails", async () => {
const { fonts, canvases } = await resolver(false);
expect(fonts.fontAvailable("Unavailable")).toBe(false);
expect(canvases.every((canvas) => !canvas.isConnected)).toBe(true);
const remove = vi.spyOn(HTMLCanvasElement.prototype, "remove");
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
throw new Error("Canvas unavailable");
});
expect(fonts.fontAvailable("Maple Mono NF CN")).toBe(true);
expect(remove).toHaveBeenCalledOnce();
});
it("does not read a locally available symbol-only Nerd fallback", async () => {
const { fonts, invoke } = await resolver("symbols");
expect(fonts.familyFaces("Symbols Nerd Font Mono")).toEqual(['"Symbols Nerd Font Mono"']);
expect(invoke).not.toHaveBeenCalled();
});
it("never reads bundled webfonts even before their CSS faces load", async () => {
const { fonts, invoke } = await resolver(false);
fonts.monoFontFamily("Inter");
fonts.monoFontFamily("JetBrains Mono");
fonts.uiFontFamily("Inter Variable");
fonts.monoFontFamily("JetBrains Mono Variable");
expect(invoke).not.toHaveBeenCalled();
});
it("does not read bundled faces or alter other platforms", async () => {
const { fonts, invoke } = await resolver(false, "windows");
fonts.monoFontFamily("Inter");
fonts.monoFontFamily("JetBrains Mono");
expect(fonts.uiFontFamily("User Font")).toBe('"User Font", system-ui, sans-serif');
expect(invoke).not.toHaveBeenCalled();
});
});
+82 -2
Переглянути файл
@@ -1,3 +1,6 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
import { guessOs } from "./platformInfo";
/**
* Monospace font-family lists shared by every consumer of the editor / terminal
* font. "JetBrains Mono" is the bundled default, but the bundle registers it as
@@ -10,6 +13,8 @@ export const BUNDLED_MONO_FAMILY = "JetBrains Mono";
export const BUNDLED_UI_FAMILY = "Inter";
const BUNDLED_FACES: ReadonlyMap<string, readonly string[]> = new Map([
[BUNDLED_MONO_FAMILY.toLowerCase(), ['"JetBrains Mono Variable"', '"JetBrains Mono"']],
["jetbrains mono variable", ['"JetBrains Mono Variable"']],
["inter variable", ['"Inter Variable"']],
[BUNDLED_UI_FAMILY.toLowerCase(), ['"Inter Variable"', '"Inter"']],
]);
const MONO_STACK_FALLBACK = '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Menlo, monospace';
@@ -22,10 +27,83 @@ export const quoteFontFamily = (family: string) =>
// Preserve compatibility with previously saved names wrapped in CSS quotes.
const familyName = (family: string) => family.trim().replace(/^"(.*)"$/, "$1");
// Per webview: the CSS alias stays stable while its faces load; DOM consumers
// reflow automatically and canvas consumers await the same promise below.
const userFonts = new Map<string, { alias: string; loaded: Promise<void> }>();
/** WKWebView needs a connected canvas to resolve user fonts. Remove it in finally after probing. */
export function createFontProbeCanvas(): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.hidden = true;
(document.body ?? document.documentElement).append(canvas);
return canvas;
}
/** A pure local-name probe; FontFaceSet.check also returns true for unknown families. */
export function fontAvailable(name: string): boolean {
let canvas: HTMLCanvasElement | undefined;
try {
canvas = createFontProbeCanvas();
const ctx = canvas.getContext("2d");
if (!ctx) return true;
// Symbol-only Nerd fonts and CJK faces may have no Latin glyphs.
return ["mmmmmmmmmmlli1WQ@", "\uF418\uE725\u{F0001}", "汉字かな한글"].some((sample) =>
["monospace", "serif", "sans-serif"].some((base) => {
ctx.font = `32px ${base}`;
const baseline = ctx.measureText(sample).width;
ctx.font = `32px ${quoteFontFamily(name)}, ${base}`;
const width = ctx.measureText(sample).width;
return Number.isFinite(width) && Number.isFinite(baseline) && width !== baseline;
}),
);
} catch {
return true;
} finally {
canvas?.remove();
}
}
async function loadUserFont(name: string, alias: string): Promise<void> {
const buffer = await invoke<ArrayBuffer>("user_font_data", { family: name });
const headerLength = new DataView(buffer).getUint32(0, true);
const start = 4 + headerLength;
const faces: { weight: number; style: string; offset: number; length: number }[] = JSON.parse(
new TextDecoder().decode(buffer.slice(4, start)),
);
await Promise.all(
faces.map(async ({ weight, style, offset, length }) => {
const face = new FontFace(alias, buffer.slice(start + offset, start + offset + length), {
weight: String(weight),
style,
});
document.fonts.add(face);
try {
await face.load();
} catch {
document.fonts.delete(face);
}
}),
);
}
/** One resolver for primary, fallback and UI families, matching the bundled CSS registrations. */
function familyFaces(family: string): readonly string[] {
export function familyFaces(family: string): readonly string[] {
const name = familyName(family);
return BUNDLED_FACES.get(name.toLowerCase()) ?? [quoteFontFamily(name)];
const bundled = BUNDLED_FACES.get(name.toLowerCase());
if (bundled) return bundled;
if (!name || !isTauri() || guessOs() !== "macos" || typeof FontFace === "undefined") return [quoteFontFamily(name)];
const key = name.toLowerCase();
let entry = userFonts.get(key);
if (!entry) {
if (fontAvailable(name)) return [quoteFontFamily(name)];
const alias = `OpenIDEA User Font ${userFonts.size}`;
const loaded = loadUserFont(name, alias).catch(() => {
/* retain local-name and bundled fallbacks */
});
entry = { alias, loaded };
userFonts.set(key, entry);
}
return [quoteFontFamily(entry.alias), quoteFontFamily(name)];
}
/** The `--font-mono` stack, resolved (canvas / xterm can't use `var()`). */
@@ -53,6 +131,8 @@ export function monoFontFamily(family: string | undefined, fallback = "", base =
export async function fontsLoaded(family: string, size: number): Promise<void> {
const fonts = typeof document !== "undefined" ? document.fonts : undefined;
if (!fonts?.load) return;
const pending = [...userFonts.values()].filter(({ alias }) => family.includes(quoteFontFamily(alias)));
if (pending.length) await Promise.all(pending.map(({ loaded }) => loaded));
try {
await Promise.all([fonts.load(`${size}px ${family}`), fonts.load(`bold ${size}px ${family}`)]);
} catch {