diff --git a/Cargo.lock b/Cargo.lock index 8d3593ee..8c1ab634 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4188,6 +4188,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-text", "serde", + "serde_json", "specta", "windows", ] @@ -6184,6 +6185,7 @@ dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "objc2-web-kit 0.3.2", "parking_lot", "serde", "serde_json", diff --git a/crates/ide-fonts/Cargo.toml b/crates/ide-fonts/Cargo.toml index 5c669527..52b849b8 100644 --- a/crates/ide-fonts/Cargo.toml +++ b/crates/ide-fonts/Cargo.toml @@ -7,10 +7,11 @@ license.workspace = true [dependencies] serde.workspace = true +serde_json.workspace = true specta.workspace = true [target.'cfg(target_os = "macos")'.dependencies] -objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] } +objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString", "CFURL"] } objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] } [target.'cfg(windows)'.dependencies] diff --git a/crates/ide-fonts/src/lib.rs b/crates/ide-fonts/src/lib.rs index 7274544e..341febde 100644 --- a/crates/ide-fonts/src/lib.rs +++ b/crates/ide-fonts/src/lib.rs @@ -1,8 +1,7 @@ //! The font families installed on this machine, for the font pickers in //! Settings (editor, typography fallback, terminal, UI font). //! -//! Each platform asks the font system the WebView itself resolves CSS -//! `font-family` names against, so every listed name renders: CoreText on +//! Each platform asks the native font system for installed families: CoreText on //! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs. //! The list is read on first use and cached until explicitly refreshed; the //! query can take a few hundred milliseconds, so callers run it off the UI and @@ -84,14 +83,164 @@ pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> { .collect() } +/// Binary font bundle: little-endian u32 JSON-header length, a face metadata +/// array, then raw font bytes. Paths never cross IPC; only CoreText-enumerated +/// files under user font directories can be read, selected by family name. +/// Includes only the nearest regular/bold faces and their optional italics. +pub fn user_font_data(family: &str) -> std::io::Result> { + #[cfg(target_os = "macos")] + { + let entries = imp::user_faces(); + bundle_family(family, &entries) + } + #[cfg(not(target_os = "macos"))] + { + let _ = family; + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "User font loading is macOS only")) + } +} + +#[cfg(any(target_os = "macos", test))] +#[derive(Debug)] +struct UserFace { + family: String, + path: std::path::PathBuf, + weight: u16, + italic: bool, +} + +#[cfg(any(target_os = "macos", test))] +fn user_font_path(path: &std::path::Path, roots: &[std::path::PathBuf]) -> Option { + let path = path.canonicalize().ok()?; + roots.iter().any(|root| path.starts_with(root)).then_some(path) +} + +#[cfg(any(target_os = "macos", test))] +fn family_faces<'a>(family: &str, entries: &'a [UserFace]) -> Vec<&'a UserFace> { + let mut selected: Vec<&UserFace> = Vec::new(); + for italic in [false, true] { + for weight in [400u16, 700] { + let closest = entries + .iter() + .filter(|e| e.family.eq_ignore_ascii_case(family.trim()) && e.italic == italic) + .min_by_key(|e| (e.weight.abs_diff(weight), e.weight, &e.path)); + if let Some(face) = closest + && !selected.iter().any(|f| std::ptr::eq(*f, face)) + { + selected.push(face); + } + } + } + selected +} + +#[cfg(any(target_os = "macos", test))] +fn bundle_family(family: &str, entries: &[UserFace]) -> std::io::Result> { + #[derive(Serialize)] + struct Face { + weight: u16, + style: &'static str, + offset: usize, + length: usize, + } + let mut faces = Vec::new(); + let mut data = Vec::new(); + let mut files = BTreeMap::new(); + // Bound large CJK families to regular/bold and their nearest italic faces. + for entry in family_faces(family, entries) { + // Reject a file replaced by a symlink after enumeration. + if entry.path.canonicalize()? != entry.path { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Font file changed")); + } + let (offset, length) = if let Some(range) = files.get(&entry.path) { + *range + } else { + let bytes = std::fs::read(&entry.path)?; + let range = (data.len(), bytes.len()); + data.extend(bytes); + files.insert(entry.path.clone(), range); + range + }; + faces.push(Face { + weight: entry.weight, + style: if entry.italic { "italic" } else { "normal" }, + offset, + length, + }); + } + if faces.is_empty() { + return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "No installed user font family")); + } + let header = serde_json::to_vec(&faces)?; + let mut out = (header.len() as u32).to_le_bytes().to_vec(); + out.extend(header); + out.extend(data); + Ok(out) +} + #[cfg(target_os = "macos")] mod imp { - use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType}; + use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType, CFURL}; use objc2_core_text::{ CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait, - kCTFontTraitsAttribute, + kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait, }; + pub(crate) fn user_faces() -> Vec { + let mut roots = vec![std::path::PathBuf::from("/Library/Fonts")]; + if let Some(home) = std::env::var_os("HOME") { + roots.push(std::path::PathBuf::from(home).join("Library/Fonts")); + } + let roots: Vec<_> = roots.into_iter().filter_map(|r| r.canonicalize().ok()).collect(); + // SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values. + unsafe { + let collection = CTFontCollection::from_available_fonts(None); + let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() }; + let mut seen = std::collections::BTreeSet::new(); + descriptors + .cast_unchecked::() + .iter() + .filter_map(|d| { + let family = d.attribute(kCTFontFamilyNameAttribute)?.downcast::().ok()?.to_string(); + let path = d.attribute(kCTFontURLAttribute)?.downcast::().ok()?.to_file_path()?; + let path = super::user_font_path(&path, &roots)?; + let traits = d.attribute(kCTFontTraitsAttribute)?.downcast::().ok()?; + let traits = traits.cast_unchecked::(); + let symbolic = traits + .get(kCTFontSymbolicTrait) + .and_then(|n| n.downcast::().ok()) + .and_then(|n| n.as_i64()) + .unwrap_or(0) as u32; + let weight = traits + .get(kCTFontWeightTrait) + .and_then(|n| n.downcast::().ok()) + .and_then(|n| n.as_f64()) + .unwrap_or(0.0); + // CoreText's normalized weight values corresponding to CSS's nine standard weights. + let weight = [ + (-0.8, 100), + (-0.6, 200), + (-0.4, 300), + (0.0, 400), + (0.23, 500), + (0.3, 600), + (0.4, 700), + (0.56, 800), + (0.62, 900), + ] + .into_iter() + .min_by(|(a, _), (b, _)| (weight - a).abs().total_cmp(&(weight - b).abs()))? + .1; + let italic = symbolic & CTFontSymbolicTraits::TraitItalic.0 != 0; + if !seen.insert((family.clone(), path.clone(), weight, italic)) { + return None; + } + Some(super::UserFace { family, path, weight, italic }) + }) + .collect() + } + } + /// Every face CoreText makes available to this process, as (family, fixed pitch). pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> { // SAFETY: CoreText's documented types: the matching descriptors array @@ -210,6 +359,92 @@ mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn selects_only_regular_bold_and_matching_italics_from_dense_families() { + let entries: Vec<_> = [false, true] + .into_iter() + .flat_map(|italic| { + (100..=900).step_by(100).map(move |weight| UserFace { + family: "CJK".into(), + path: format!("{weight}-{italic}.ttf").into(), + weight, + italic, + }) + }) + .collect(); + let selected = family_faces(" cjk ", &entries); + assert_eq!( + selected.iter().map(|f| (f.weight, f.italic)).collect::>(), + [(400, false), (700, false), (400, true), (700, true),] + ); + assert!(family_faces("unknown", &entries).is_empty()); + } + + #[test] + fn selects_nearest_available_weights_once_with_optional_italics() { + let entries: Vec<_> = [(300, false), (500, false), (800, false), (500, true)] + .into_iter() + .map(|(weight, italic)| UserFace { + family: "Sparse".into(), + path: format!("{weight}-{italic}.ttf").into(), + weight, + italic, + }) + .collect(); + let selected = family_faces("Sparse", &entries); + assert_eq!( + selected.iter().map(|f| (f.weight, f.italic)).collect::>(), + [(300, false), (800, false), (500, true),] + ); + assert_eq!(family_faces("Sparse", &entries[..1]).len(), 1); + } + + #[test] + fn bundle_only_reads_enumerated_family_faces() { + let root = std::env::current_dir().unwrap().join(format!("../../.font-bundle-test-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let root = root.canonicalize().unwrap(); + let regular = root.join("regular.ttf"); + let bold = root.join("bold.ttf"); + std::fs::write(®ular, [1, 2, 3]).unwrap(); + std::fs::write(&bold, [4, 5]).unwrap(); + let entries = [ + UserFace { family: "Test Nerd Font".into(), path: regular.clone(), weight: 400, italic: false }, + UserFace { family: "Test Nerd Font".into(), path: bold, weight: 700, italic: false }, + // A non-selected face is never opened, even if its file has disappeared. + UserFace { family: "Test Nerd Font".into(), path: root.join("thin.ttf"), weight: 100, italic: false }, + ]; + let result = bundle_family("test nerd font", &entries).unwrap(); + let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize; + let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap(); + assert_eq!(faces[0]["weight"], 400); + assert_eq!(faces[1]["weight"], 700); + assert_eq!(faces.as_array().unwrap().len(), 2); + assert_eq!(&result[4 + len..], &[1, 2, 3, 4, 5]); + // Existing files are not inputs: neither paths nor unenumerated families grant access. + assert!(bundle_family(regular.to_str().unwrap(), &entries).is_err()); + assert!(bundle_family("Unenumerated", &entries).is_err()); + assert!(user_font_path(®ular, &[]).is_none()); + assert!(user_font_path(®ular, std::slice::from_ref(&root)).is_some()); + // Multiple selected descriptors in a collection still transfer the file only once. + let shared = [ + UserFace { family: "Collection".into(), path: regular.clone(), weight: 400, italic: false }, + UserFace { family: "Collection".into(), path: regular, weight: 700, italic: false }, + ]; + let result = bundle_family("Collection", &shared).unwrap(); + let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize; + let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap(); + assert_eq!(faces[0]["offset"], faces[1]["offset"]); + assert_eq!(&result[4 + len..], &[1, 2, 3]); + #[cfg(unix)] + { + let link = root.join("outside.ttf"); + std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap(); + assert!(user_font_path(&link, std::slice::from_ref(&root)).is_none()); + } + std::fs::remove_dir_all(root).unwrap(); + } + fn fam(name: &str, monospaced: bool) -> FontFamily { FontFamily { name: name.into(), monospaced } } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 9e1bfb82..2f883b7b 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -235,6 +235,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "agent_registry_remove" | "coverage_import" | "system_font_families" + | "user_font_data" | "plugin_subscribe" | "plugin_list" | "plugin_set_active_file" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc561366..131f729a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -75,3 +75,5 @@ block2 = "0.6" objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "NSView", "NSWindow"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } +# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions. +objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] } diff --git a/src-tauri/src/commands/fonts.rs b/src-tauri/src/commands/fonts.rs index 705da8e2..8a27a0fa 100644 --- a/src-tauri/src/commands/fonts.rs +++ b/src-tauri/src/commands/fonts.rs @@ -13,3 +13,13 @@ pub async fn system_font_families(refresh: bool) -> IdeResult> { .await .map_err(|e| IdeError::internal(e.to_string())) } + +/// Raw font bundle, selected by family, never by a frontend-supplied path. +#[tauri::command] +pub async fn user_font_data(family: String) -> IdeResult { + tauri::async_runtime::spawn_blocking(move || ide_fonts::user_font_data(&family)) + .await + .map_err(|e| IdeError::internal(e.to_string()))? + .map(tauri::ipc::Response::new) + .map_err(|e| IdeError::invalid(e.to_string())) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2af828bb..4cfeb1ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -87,7 +87,12 @@ pub fn run() { // Paths macOS asked to open (Finder's Open With, the Dock) before setup ran: the first window takes them. let early_opens: Arc>>> = Arc::default(); let setup_opens = early_opens.clone(); - let invoke = Arc::new(builder.invoke_handler()); + let typed_invoke = builder.invoke_handler(); + // Raw Response is not a JSON/Specta type; keep the binary command out of generated bindings. + let raw_invoke: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![commands::fonts::user_font_data]; + let invoke = Arc::new(move |request: tauri::ipc::Invoke| { + if request.message.command() == "user_font_data" { raw_invoke(request) } else { typed_invoke(request) } + }); app.invoke_handler(move |request| { let webview = request.message.webview_ref(); if let Some(state) = webview.try_state::() { diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index ebeb09f3..30a13ed8 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -294,7 +294,7 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< config.background_color = Some(frame_color(a, false)); } let window = WebviewWindowBuilder::from_config(app, &config) - .and_then(|b| b.build()) + .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; apply_zoom(app, &window); #[cfg(target_os = "macos")] @@ -760,7 +760,7 @@ pub fn open_settings_window( #[cfg(not(target_os = "macos"))] let builder = builder.and_then(|b| b.parent(&parent)); let window = builder - .and_then(|b| b.build()) + .and_then(|b| dev_font_preferences(b).build()) .map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?; apply_zoom(app, &window); #[cfg(target_os = "macos")] @@ -874,6 +874,39 @@ fn centered(px: f64, py: f64, pw: f64, ph: f64, w: f64, h: f64) -> (f64, f64) { (px + (pw - w) / 2.0, py + ((ph - h) / 2.0).max(0.0)) } +/// Reproduce WKWebView's user-font restriction before WKWebView creation. The +/// private selector and environment override are absent from release builds. +#[cfg(all(debug_assertions, target_os = "macos"))] +fn dev_font_preferences( + builder: WebviewWindowBuilder<'_, tauri::Wry, AppHandle>, +) -> WebviewWindowBuilder<'_, tauri::Wry, AppHandle> { + if std::env::var("OPENIDEA_DEV_DISALLOW_USER_FONTS").as_deref() == Ok("1") { + use objc2::{MainThreadMarker, msg_send, sel}; + use objc2_foundation::NSObjectProtocol; + let config = unsafe { + objc2_web_kit::WKWebViewConfiguration::new(MainThreadMarker::new().expect("window creation on main thread")) + }; + let preferences = unsafe { config.preferences() }; + if preferences.respondsToSelector(sel!(_setShouldAllowUserInstalledFonts:)) { + unsafe { + let _: () = msg_send![&preferences, _setShouldAllowUserInstalledFonts: false]; + } + let allowed: bool = unsafe { msg_send![&preferences, _shouldAllowUserInstalledFonts] }; + tracing::info!(allowed, "Development user-installed fonts override"); + return builder.with_webview_configuration(config); + } + tracing::warn!("WKPreferences user-font development selector unavailable"); + } + builder +} + +#[cfg(not(all(debug_assertions, target_os = "macos")))] +fn dev_font_preferences( + builder: WebviewWindowBuilder<'_, tauri::Wry, AppHandle>, +) -> WebviewWindowBuilder<'_, tauri::Wry, AppHandle> { + builder +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/features/terminal/theme.ts b/src/features/terminal/theme.ts index 5996a12d..5eeb02b9 100644 --- a/src/features/terminal/theme.ts +++ b/src/features/terminal/theme.ts @@ -6,7 +6,7 @@ */ import type { ITheme, Terminal } from "@xterm/xterm"; import { fontWeightOf } from "@/platform/editorSettings"; -import { BUNDLED_MONO_FAMILY, fontsLoaded, monoFontFamily, monoStack } from "@/platform/fonts"; +import { BUNDLED_MONO_FAMILY, familyFaces, fontsLoaded, monoFontFamily, monoStack } from "@/platform/fonts"; import { parseColor, type Rgba } from "@/platform/cssColor"; export { parseColor, type Rgba }; @@ -152,9 +152,7 @@ const NERD_FONT_FALLBACKS = [ "FiraCode Nerd Font", "Hasklug Nerd Font", "CaskaydiaCove Nerd Font", -] - .map((f) => `"${f}"`) - .join(", "); +]; export interface TerminalFontSettings { useEditorFont: boolean; @@ -189,7 +187,8 @@ export interface TerminalFont { * common installs before the generic family so per-glyph fallback finds them. */ function withNerdFonts(family: string): string { - return `${family.replace(/,\s*monospace\s*$/i, "")}, ${NERD_FONT_FALLBACKS}, monospace`; + const fallbacks = NERD_FONT_FALLBACKS.flatMap(familyFaces).join(", "); + return `${family.replace(/,\s*monospace\s*$/i, "")}, ${fallbacks}, monospace`; } function cssNumber(name: string): number { diff --git a/src/features/viewers/markdown/mermaid.ts b/src/features/viewers/markdown/mermaid.ts index 903d190c..cce3cca2 100644 --- a/src/features/viewers/markdown/mermaid.ts +++ b/src/features/viewers/markdown/mermaid.ts @@ -3,6 +3,7 @@ * `[data-mermaid]` placeholder holding the source in `pre > code`; this module * turns placeholders into SVG. The library is imported on first use only. */ +import { fontsLoaded } from "@/platform/fonts"; import type { Mermaid } from "mermaid"; import { resolveTokenColor, toRgb } from "@/platform/cssColor"; import { attachPanZoom } from "../panZoom"; @@ -32,7 +33,9 @@ function loadMermaid(): Promise { export function themeKey(): string { const root = document.documentElement; const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset; - return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${root.style.getPropertyValue("--accent")}`; + const accent = root.style.getPropertyValue("--accent"); + const font = getComputedStyle(root).getPropertyValue("--font-ui"); + return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${accent}|${font}`; } /** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */ @@ -102,6 +105,7 @@ function removeTemp(id: string) { async function renderOne(source: string, key: string): Promise { const mermaid = await loadMermaid(); + await fontsLoaded(getComputedStyle(document.documentElement).getPropertyValue("--font-ui").trim(), 16); configure(mermaid, key); const id = `openidea-mermaid-${++seq}`; try { diff --git a/src/platform/candidateFonts.test.ts b/src/platform/candidateFonts.test.ts index e9ea10c0..a83cb22d 100644 --- a/src/platform/candidateFonts.test.ts +++ b/src/platform/candidateFonts.test.ts @@ -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(() => { diff --git a/src/platform/candidateFonts.ts b/src/platform/candidateFonts.ts index e566fc36..72d9d897 100644 --- a/src/platform/candidateFonts.ts +++ b/src/platform/candidateFonts.ts @@ -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(); } } diff --git a/src/platform/fonts.test.ts b/src/platform/fonts.test.ts index 00f23c6d..f8060c04 100644 --- a/src/platform/fonts.test.ts +++ b/src/platform/fonts.test.ts @@ -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(); + }); +}); diff --git a/src/platform/fonts.ts b/src/platform/fonts.ts index dc0b7cd7..161a9873 100644 --- a/src/platform/fonts.ts +++ b/src/platform/fonts.ts @@ -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 = 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 }>(); + +/** 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 { + const buffer = await invoke("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 { 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 {