mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
+239
-4
@@ -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<Vec<u8>> {
|
||||
#[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<std::path::PathBuf> {
|
||||
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<Vec<u8>> {
|
||||
#[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<super::UserFace> {
|
||||
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::<CTFontDescriptor>()
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
let family = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
|
||||
let path = d.attribute(kCTFontURLAttribute)?.downcast::<CFURL>().ok()?.to_file_path()?;
|
||||
let path = super::user_font_path(&path, &roots)?;
|
||||
let traits = d.attribute(kCTFontTraitsAttribute)?.downcast::<CFDictionary>().ok()?;
|
||||
let traits = traits.cast_unchecked::<CFString, CFType>();
|
||||
let symbolic = traits
|
||||
.get(kCTFontSymbolicTrait)
|
||||
.and_then(|n| n.downcast::<CFNumber>().ok())
|
||||
.and_then(|n| n.as_i64())
|
||||
.unwrap_or(0) as u32;
|
||||
let weight = traits
|
||||
.get(kCTFontWeightTrait)
|
||||
.and_then(|n| n.downcast::<CFNumber>().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::<Vec<_>>(),
|
||||
[(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::<Vec<_>>(),
|
||||
[(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 }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -13,3 +13,13 @@ pub async fn system_font_families(refresh: bool) -> IdeResult<Vec<FontFamily>> {
|
||||
.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::ipc::Response> {
|
||||
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()))
|
||||
}
|
||||
|
||||
@@ -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<Mutex<Option<Vec<PathBuf>>>> = 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::<AppState>() {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Mermaid> {
|
||||
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<Result> {
|
||||
const mermaid = await loadMermaid();
|
||||
await fontsLoaded(getComputedStyle(document.documentElement).getPropertyValue("--font-ui").trim(), 16);
|
||||
configure(mermaid, key);
|
||||
const id = `openidea-mermaid-${++seq}`;
|
||||
try {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user