From d2994b29fdf39ac425f6e7871886d77c46d33f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Mu=C3=B1oz?= Date: Wed, 5 Aug 2026 14:54:49 +0200 Subject: [PATCH 1/4] feat: FontDelivery domain with pinned, hash-verified Typeface manifest (#29) --- CHANGELOG.md | 1 + Cargo.lock | 428 ++++++++++++++++++++ Cargo.toml | 3 + src/font_delivery.rs | 937 +++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 5 files changed, 1370 insertions(+) create mode 100644 src/font_delivery.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e419344..a924fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The OS window title reflects the visible Document title: the first content line, or the `Untitled` placeholder for empty Documents. - Un-vetoable termination (macOS Cmd+Q, system quit) preserves conflicted local content as a distinct collision-safe copy so hard quit cannot lose writing. - Typed Preferences domain with Appearance defaults (System Theme mapping Paper and Ink, Comfortable Writing width, zero Letter spacing, System monospace Typeface, 16px Font size, Regular Body and Bold Document-title weights), a title-heavier-than-body validation invariant, and atomic persistence; the app config now keeps session bookkeeping and Appearance in separate typed sections and migrates existing configs on their next write without losing the last-opened File (Phase 4.2). +- Curated editor Typefaces are delivered on demand through a fixed manifest pinned to canonical upstream releases or commits (Geist Mono, JetBrains Mono, iA Writer Mono, Fira Code, Source Code Pro): SHA-256-verified downloads, an app-local cache under the platform cache directory, and retryable, offline-safe behavior — nothing is installed into the operating system (Phase 4.3). ### Changed diff --git a/Cargo.lock b/Cargo.lock index 10a892b..69a7f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -634,6 +643,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -681,6 +701,17 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "dlib" version = "0.5.3" @@ -1122,6 +1153,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.33" @@ -1433,6 +1473,109 @@ dependencies = [ "objc2 0.4.1", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "im" version = "15.1.0" @@ -1635,9 +1778,12 @@ dependencies = [ "floem", "serde", "serde_json", + "sha2", "signal-hook", "time", "unicode-normalization", + "ureq", + "zip", ] [[package]] @@ -1692,6 +1838,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "litrs" version = "1.0.0" @@ -2214,6 +2366,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2466,6 +2627,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -2504,6 +2679,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2853,6 +3063,12 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2887,6 +3103,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -2941,6 +3163,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -3083,6 +3316,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.12.0" @@ -3265,6 +3508,40 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "usvg" version = "0.43.0" @@ -3292,6 +3569,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version_check" version = "0.9.5" @@ -3558,6 +3841,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -3750,6 +4051,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -3961,6 +4271,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x11-clipboard" version = "0.9.3" @@ -4046,6 +4362,29 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeno" version = "0.2.3" @@ -4072,12 +4411,101 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.19", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 2eae6a0..a1488ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,12 @@ ink-dev = [] [dependencies] dirs = "6.0" serde = { version = "1", features = ["derive"] } +sha2 = "0.10" unicode-normalization = "0.1" time = { version = "0.3", features = ["formatting", "local-offset"] } serde_json = "1" +ureq = { version = "2", features = ["tls"] } +zip = { version = "2", default-features = false, features = ["deflate"] } floem = { version = "0.2", default-features = false, features = ["editor", "vger"] } [target.'cfg(unix)'.dependencies] diff --git a/src/font_delivery.rs b/src/font_delivery.rs new file mode 100644 index 0000000..8ea2704 --- /dev/null +++ b/src/font_delivery.rs @@ -0,0 +1,937 @@ +//! FontDelivery (Phase 4.3): curated editor Typefaces on demand. +//! +//! A fixed, verifiable manifest pins each curated Typeface to its canonical +//! upstream source at an immutable release version or commit. Downloads are +//! cached under Lettera's platform cache directory, accepted only after +//! SHA-256 verification, and never installed into the operating system. A +//! missing, cleared, or invalid cache entry keeps the writer on the current +//! Typeface, retains the desired Typeface Preference, and can restore the +//! Typeface in the background: `ensure` is a single retryable operation and a +//! failed download never blocks launching or writing. +//! +//! The manifest covers the five curated editor Typefaces from `preferences`; +//! System monospace is not downloadable. Four faces are served as raw TTF +//! files at their pinned tag or commit; Fira Code publishes no TTF in its +//! release tree, so it is pinned to the official release archive and its TTF +//! assets are extracted from it. Hashes are captured from the canonical +//! upstream files at pin time and are immutable with the pin. + +use std::fmt; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::atomic; +use crate::preferences::{FontWeight, Typeface}; + +/// A SHA-256 digest of a verified font asset. +pub type Sha256Digest = [u8; 32]; + +/// Licence record pinned with every curated Typeface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LicenceRecord { + /// Licence identifier (every curated face is SIL Open Font License 1.1). + pub name: &'static str, + /// Copyright and attribution notice from the official licence file. + pub notice: &'static str, + /// Canonical licence file at the pinned revision. + pub source: &'static str, +} + +/// One verified static TTF asset of a curated Typeface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FontAsset { + /// Real weight this asset provides (Regular or Bold). + pub weight: FontWeight, + /// Canonical upstream URL at an immutable pin. Either a raw TTF file, or + /// a release archive when [`Self::archive_asset`] is set. + pub source: &'static str, + /// Immutable release version or commit the pin is anchored to. Never a + /// mutable branch. + pub pin: &'static str, + /// Path of the TTF inside the archive when `source` is a release archive. + pub archive_asset: Option<&'static str>, + /// SHA-256 of the exact TTF bytes, captured from the canonical upstream + /// file at pin time. + pub sha256: Sha256Digest, +} + +/// A curated editor Typeface in the fixed manifest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ManifestEntry { + pub typeface: Typeface, + pub licence: LicenceRecord, + /// The Regular asset every curated face provides. + pub regular: FontAsset, + /// The Bold asset every curated face provides. + pub bold: FontAsset, +} + +/// The fixed manifest of curated editor Typefaces. +pub fn manifest() -> &'static [ManifestEntry] { + &MANIFEST +} + +/// Manifest entry for a curated Typeface, or `None` for System monospace, +/// which is not downloadable. +pub fn entry_for(typeface: Typeface) -> Option<&'static ManifestEntry> { + find_entry(manifest(), typeface) +} + +fn find_entry(manifest: &[ManifestEntry], typeface: Typeface) -> Option<&ManifestEntry> { + manifest.iter().find(|entry| entry.typeface == typeface) +} + +/// Downloads the bytes of a canonical URL. +/// +/// The production implementation is a plain HTTPS request; tests substitute a +/// fake fetcher with controlled bytes and failures, so no test contacts a +/// public font host or the network. +pub trait Fetcher { + fn fetch(&self, url: &str) -> Result, String>; +} + +/// Upper bound for any single download or extracted archive entry. Real fonts +/// are a few megabytes at most; the cap keeps a misbehaving source from +/// ballooning memory before hash verification rejects its bytes. +const MAX_FONT_BYTES: usize = 64 * 1024 * 1024; + +/// Production fetcher: a plain HTTPS download with `ureq`. +pub struct HttpFetcher; + +impl Fetcher for HttpFetcher { + fn fetch(&self, url: &str) -> Result, String> { + let response = ureq::get(url) + .call() + .map_err(|error| format!("could not download {url}: {error}"))?; + let mut reader = response.into_reader().take(MAX_FONT_BYTES as u64 + 1); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|error| format!("could not read download {url}: {error}"))?; + if bytes.len() > MAX_FONT_BYTES { + return Err(format!("download of {url} exceeds the size limit")); + } + Ok(bytes) + } +} + +/// A failure in Typeface delivery. Every error is actionable and leaves the +/// writer on the current Typeface; none blocks launching or writing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DeliveryError { + /// The download failed (network, status, or read error). Retryable. + Fetch(String), + /// Downloaded bytes did not match the pinned SHA-256. Redownloadable. + Invalid(String), + /// The platform cache directory is unavailable or could not be written. + Cache(String), + /// The Typeface is not downloadable (System monospace). + NotDownloadable, +} + +impl fmt::Display for DeliveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Fetch(message) => write!(formatter, "could not download Typeface: {message}"), + Self::Invalid(message) => { + write!(formatter, "downloaded Typeface is invalid: {message}") + } + Self::Cache(message) => write!(formatter, "Typeface cache failure: {message}"), + Self::NotDownloadable => formatter.write_str("this Typeface is not downloadable"), + } + } +} + +/// Verified TTF bytes of a Typeface's Regular and Bold assets. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedAssets { + pub regular: Vec, + pub bold: Vec, +} + +/// Domain seam coordinating manifest, app-local cache, download, hash +/// validation, and retry for curated editor Typefaces. +pub struct FontDelivery<'manifest, F> { + fetcher: F, + cache: Option, + manifest: &'manifest [ManifestEntry], +} + +impl<'manifest, F: Fetcher> FontDelivery<'manifest, F> { + /// Delivery against an explicit cache directory (test seam). + pub fn at(cache: impl Into, fetcher: F, manifest: &'manifest [ManifestEntry]) -> Self { + Self { + fetcher, + cache: Some(cache.into()), + manifest, + } + } + + fn entry(&self, typeface: Typeface) -> Option<&ManifestEntry> { + find_entry(self.manifest, typeface) + } + + /// Verified cached assets for the Typeface, if both are present and match + /// the pinned hashes. Never touches the network. + pub fn cached(&self, typeface: Typeface) -> Option { + let entry = self.entry(typeface)?; + let cache = self.cache.as_ref()?; + self.cached_assets(cache, entry) + } + + fn cached_assets(&self, cache: &Path, entry: &ManifestEntry) -> Option { + let regular = read_verified( + &cache_path(cache, entry.typeface, entry.regular.weight), + entry.regular.sha256, + )?; + let bold = read_verified( + &cache_path(cache, entry.typeface, entry.bold.weight), + entry.bold.sha256, + )?; + Some(VerifiedAssets { regular, bold }) + } + + /// Ensures the Typeface's verified assets are cached, downloading and + /// validating them when needed, and returns the verified TTF bytes. + /// + /// Idempotent: a valid cache entry is reused. A failed asset leaves the + /// successful one cached; retrying the same call resumes where it stopped. + pub fn ensure(&self, typeface: Typeface) -> Result { + let entry = self.entry(typeface).ok_or(DeliveryError::NotDownloadable)?; + let cache = self.cache.as_deref().ok_or_else(|| { + DeliveryError::Cache("no platform cache directory is available".to_owned()) + })?; + if let Some(cached) = self.cached_assets(cache, entry) { + return Ok(cached); + } + // When both assets share one release archive, download it once instead + // of fetching the same archive twice. + if entry.regular.archive_asset.is_some() + && entry.bold.archive_asset.is_some() + && entry.regular.source == entry.bold.source + { + let archive = self + .fetcher + .fetch(entry.regular.source) + .map_err(DeliveryError::Fetch)?; + let regular = self.store_verified(cache, entry, &entry.regular, &archive)?; + let bold = self.store_verified(cache, entry, &entry.bold, &archive)?; + return Ok(VerifiedAssets { regular, bold }); + } + let regular = self.ensure_asset(cache, entry, &entry.regular)?; + let bold = self.ensure_asset(cache, entry, &entry.bold)?; + Ok(VerifiedAssets { regular, bold }) + } + + fn ensure_asset( + &self, + cache: &Path, + entry: &ManifestEntry, + asset: &FontAsset, + ) -> Result, DeliveryError> { + let path = cache_path(cache, entry.typeface, asset.weight); + if let Some(verified) = read_verified(&path, asset.sha256) { + return Ok(verified); + } + let downloaded = self + .fetcher + .fetch(asset.source) + .map_err(DeliveryError::Fetch)?; + self.store_verified(cache, entry, asset, &downloaded) + } + + fn store_verified( + &self, + cache: &Path, + entry: &ManifestEntry, + asset: &FontAsset, + downloaded: &[u8], + ) -> Result, DeliveryError> { + let bytes = match asset.archive_asset { + Some(inner) => { + extract_archive_asset(downloaded, inner).map_err(DeliveryError::Invalid)? + } + None => downloaded.to_vec(), + }; + if digest(&bytes) != asset.sha256 { + return Err(DeliveryError::Invalid(format!( + "{} {} does not match its pinned SHA-256", + typeface_slug(entry.typeface), + weight_slug(asset.weight) + ))); + } + let path = cache_path(cache, entry.typeface, asset.weight); + fs::create_dir_all(cache).map_err(|error| { + DeliveryError::Cache(format!("could not create {}: {error}", cache.display())) + })?; + atomic::write(&path, &bytes).map_err(|error| { + DeliveryError::Cache(format!("could not write {}: {error}", path.display())) + })?; + Ok(bytes) + } + + /// Removes the Typeface's cached assets. Harmless: a later `ensure` + /// refetches verified bytes. + pub fn clear_cache(&self, typeface: Typeface) -> Result<(), DeliveryError> { + let Some(cache) = &self.cache else { + return Ok(()); + }; + let Some(entry) = self.entry(typeface) else { + return Ok(()); + }; + for asset in [entry.regular, entry.bold] { + let path = cache_path(cache, typeface, asset.weight); + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(DeliveryError::Cache(format!( + "could not clear cached Typeface {}: {error}", + path.display() + ))); + } + } + } + Ok(()) + } +} + +impl FontDelivery<'static, HttpFetcher> { + /// Production delivery against the platform cache directory. + pub fn new() -> Self { + Self { + fetcher: HttpFetcher, + cache: default_cache_directory(), + manifest: manifest(), + } + } +} + +impl Default for FontDelivery<'static, HttpFetcher> { + fn default() -> Self { + Self::new() + } +} + +/// Platform-standard cache directory for downloaded Typefaces. +pub(crate) fn default_cache_directory() -> Option { + dirs::cache_dir().map(|directory| { + directory + .join(crate::preferences::CONFIG_DIR_NAME) + .join("fonts") + }) +} + +fn cache_path(cache: &Path, typeface: Typeface, weight: FontWeight) -> PathBuf { + cache.join(format!( + "{}-{}.ttf", + typeface_slug(typeface), + weight_slug(weight) + )) +} + +fn read_verified(path: &Path, expected: Sha256Digest) -> Option> { + let bytes = fs::read(path).ok()?; + (digest(&bytes) == expected).then_some(bytes) +} + +fn digest(bytes: &[u8]) -> Sha256Digest { + Sha256::digest(bytes).into() +} + +fn extract_archive_asset(archive: &[u8], asset: &str) -> Result, String> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(archive)) + .map_err(|error| format!("could not read release archive: {error}"))?; + let mut file = archive + .by_name(asset) + .map_err(|error| format!("could not find {asset} in the release archive: {error}"))?; + let mut bytes = Vec::new(); + (&mut file) + .take(MAX_FONT_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("could not read {asset} from the release archive: {error}"))?; + if bytes.len() > MAX_FONT_BYTES { + return Err(format!("{asset} exceeds the size limit")); + } + Ok(bytes) +} + +fn typeface_slug(typeface: Typeface) -> &'static str { + match typeface { + Typeface::System => "system", + Typeface::GeistMono => "geist_mono", + Typeface::JetBrainsMono => "jetbrains_mono", + Typeface::IaWriterMono => "ia_writer_mono", + Typeface::FiraCode => "fira_code", + Typeface::SourceCodePro => "source_code_pro", + } +} + +fn weight_slug(weight: FontWeight) -> &'static str { + match weight { + FontWeight::Regular => "regular", + FontWeight::Bold => "bold", + _ => panic!("manifest asset weight must be Regular or Bold"), + } +} + +/// The fixed, verified manifest. Every asset is anchored to an immutable +/// release or commit; hashes were captured from the canonical upstream files. +/// Four faces are served as raw TTF files at their pinned tag or commit; Fira +/// Code publishes no TTF in its release tree, so it is pinned to the official +/// release archive and its assets are extracted from it. +#[rustfmt::skip] +const MANIFEST: [ManifestEntry; 5] = [ + ManifestEntry { + typeface: Typeface::GeistMono, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "Copyright © 2023 Vercel with basement.studio", + source: "https://raw.githubusercontent.com/vercel/geist-font/v1.7.2/LICENSE.txt", + }, + regular: FontAsset { + weight: FontWeight::Regular, + source: "https://raw.githubusercontent.com/vercel/geist-font/v1.7.2/fonts/GeistMono/ttf/GeistMono-Regular.ttf", + pin: "v1.7.2", + archive_asset: None, + sha256: parse_digest("5a0de4b3d54ab272f76a1d8c84b7fb24c67bbec6591d5300e61c7bc10094b6c8"), + }, + bold: FontAsset { + weight: FontWeight::Bold, + source: "https://raw.githubusercontent.com/vercel/geist-font/v1.7.2/fonts/GeistMono/ttf/GeistMono-Bold.ttf", + pin: "v1.7.2", + archive_asset: None, + sha256: parse_digest("325de0913317e63c9b0084d3f571c6d1c9279e776e9260c62be19e1c12d1be3c"), + }, + }, + ManifestEntry { + typeface: Typeface::JetBrainsMono, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "JetBrains Mono Project Authors", + source: "https://raw.githubusercontent.com/JetBrains/JetBrainsMono/v2.304/OFL.txt", + }, + regular: FontAsset { + weight: FontWeight::Regular, + source: "https://raw.githubusercontent.com/JetBrains/JetBrainsMono/v2.304/fonts/ttf/JetBrainsMono-Regular.ttf", + pin: "v2.304", + archive_asset: None, + sha256: parse_digest("a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f"), + }, + bold: FontAsset { + weight: FontWeight::Bold, + source: "https://raw.githubusercontent.com/JetBrains/JetBrainsMono/v2.304/fonts/ttf/JetBrainsMono-Bold.ttf", + pin: "v2.304", + archive_asset: None, + sha256: parse_digest("5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb"), + }, + }, + ManifestEntry { + typeface: Typeface::IaWriterMono, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "Information Architects Inc. and IBM Corp. — Reserved Font Names: iA Writer, Plex", + source: "https://raw.githubusercontent.com/iaolo/iA-Fonts/f32c04c3058a75d7ce28919ce70fe8800817491b/iA%20Writer%20Mono/LICENSE.md", + }, + regular: FontAsset { + weight: FontWeight::Regular, + source: "https://raw.githubusercontent.com/iaolo/iA-Fonts/f32c04c3058a75d7ce28919ce70fe8800817491b/iA%20Writer%20Mono/Static/iAWriterMonoS-Regular.ttf", + pin: "f32c04c3058a75d7ce28919ce70fe8800817491b", + archive_asset: None, + sha256: parse_digest("929605302a57250e712908cb5f6e1ce80c7d0accd5fd2555345f29a5e8d4e30b"), + }, + bold: FontAsset { + weight: FontWeight::Bold, + source: "https://raw.githubusercontent.com/iaolo/iA-Fonts/f32c04c3058a75d7ce28919ce70fe8800817491b/iA%20Writer%20Mono/Static/iAWriterMonoS-Bold.ttf", + pin: "f32c04c3058a75d7ce28919ce70fe8800817491b", + archive_asset: None, + sha256: parse_digest("76aa5b5b4f9091a9c686a2a6fe5ff3495bb791994d7079857e5b24ae98063743"), + }, + }, + ManifestEntry { + typeface: Typeface::FiraCode, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "Fira Code Project Authors", + source: "https://raw.githubusercontent.com/tonsky/FiraCode/6.2/LICENSE", + }, + regular: FontAsset { + weight: FontWeight::Regular, + source: "https://github.com/tonsky/FiraCode/releases/download/6.2/Fira_Code_v6.2.zip", + pin: "6.2", + archive_asset: Some("ttf/FiraCode-Regular.ttf"), + sha256: parse_digest("5992ab9640e2df491b2f609467b1de60e8bc39b2c28db184342a0592d98f6117"), + }, + bold: FontAsset { + weight: FontWeight::Bold, + source: "https://github.com/tonsky/FiraCode/releases/download/6.2/Fira_Code_v6.2.zip", + pin: "6.2", + archive_asset: Some("ttf/FiraCode-Bold.ttf"), + sha256: parse_digest("41f6554e845e2f5b70adad3950122334b866aac436793b7742ade600067701be"), + }, + }, + ManifestEntry { + typeface: Typeface::SourceCodePro, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "Copyright © 2023 Adobe — Reserved Font Name: Source", + source: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/2.042R-u%2F1.062R-i%2F1.026R-vf/LICENSE.md", + }, + regular: FontAsset { + weight: FontWeight::Regular, + source: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/2.042R-u%2F1.062R-i%2F1.026R-vf/TTF/SourceCodePro-Regular.ttf", + pin: "2.042R-u/1.062R-i/1.026R-vf", + archive_asset: None, + sha256: parse_digest("74bd80d3e42a08517cd7e1108ba3d86f2da29ac0f3065be95e0357956ab9db37"), + }, + bold: FontAsset { + weight: FontWeight::Bold, + source: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/2.042R-u%2F1.062R-i%2F1.026R-vf/TTF/SourceCodePro-Bold.ttf", + pin: "2.042R-u/1.062R-i/1.026R-vf", + archive_asset: None, + sha256: parse_digest("b2095e0d657e6d28dc32444a9dacabab0c9241d0bf39d96371756cc9bdbc3a5f"), + }, + }, +]; + +/// Parses a 64-character lowercase hex digest into 32 bytes at compile time. +const fn parse_digest(hex: &str) -> Sha256Digest { + let bytes = hex.as_bytes(); + let mut digest = [0u8; 32]; + let mut index = 0; + while index < 32 { + let high = hex_value(bytes[index * 2]); + let low = hex_value(bytes[index * 2 + 1]); + digest[index] = (high << 4) | low; + index += 1; + } + digest +} + +const fn hex_value(byte: u8) -> u8 { + match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + _ => panic!("manifest digest must be lowercase hex"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::temporary_directory; + use std::cell::Cell; + use std::collections::HashMap; + use std::fs; + use std::io::Write; + use std::rc::Rc; + + const REGULAR_BYTES: &[u8] = b"fake geist regular bytes"; + const BOLD_BYTES: &[u8] = b"fake geist bold bytes"; + + fn test_digest(bytes: &[u8]) -> Sha256Digest { + Sha256::digest(bytes).into() + } + + fn raw_asset(weight: FontWeight, name: &str) -> FontAsset { + FontAsset { + weight, + source: format!("https://example.invalid/{name}").leak(), + pin: "v1.7.2", + archive_asset: None, + sha256: test_digest(if weight == FontWeight::Regular { + REGULAR_BYTES + } else { + BOLD_BYTES + }), + } + } + + fn test_entry() -> ManifestEntry { + ManifestEntry { + typeface: Typeface::GeistMono, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "Test Authors", + source: "https://example.invalid/OFL.txt", + }, + regular: raw_asset(FontWeight::Regular, "GeistMono-Regular.ttf"), + bold: raw_asset(FontWeight::Bold, "GeistMono-Bold.ttf"), + } + } + + #[derive(Clone)] + struct FakeFetcher { + responses: Rc, String>>>, + calls: Rc>, + } + + impl FakeFetcher { + fn new(responses: HashMap, String>>) -> Self { + Self { + responses: Rc::new(responses), + calls: Rc::new(Cell::new(0)), + } + } + + fn calls(&self) -> usize { + self.calls.get() + } + } + + impl Fetcher for FakeFetcher { + fn fetch(&self, url: &str) -> Result, String> { + self.calls.set(self.calls.get() + 1); + self.responses + .get(url) + .cloned() + .unwrap_or_else(|| Err(format!("unexpected fetch of {url}"))) + } + } + + #[test] + fn manifest_pins_the_five_curated_editor_typefaces() { + let faces: Vec = manifest().iter().map(|entry| entry.typeface).collect(); + assert_eq!( + faces, + vec![ + Typeface::GeistMono, + Typeface::JetBrainsMono, + Typeface::IaWriterMono, + Typeface::FiraCode, + Typeface::SourceCodePro, + ] + ); + assert_eq!(entry_for(Typeface::System), None); + } + + #[test] + fn manifest_pins_immutable_releases_or_commits() { + for entry in manifest() { + for asset in [entry.regular, entry.bold] { + assert!(!asset.pin.is_empty()); + assert!( + !matches!(asset.pin, "master" | "main" | "latest" | "develop" | "HEAD"), + "{:?} must not follow a mutable branch", + entry.typeface + ); + assert!(asset.source.starts_with("https://")); + } + } + // The untagged iA source is anchored to a full commit hash. + let ia = entry_for(Typeface::IaWriterMono).unwrap(); + for asset in [ia.regular, ia.bold] { + assert_eq!(asset.pin.len(), 40); + assert!(asset.pin.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + } + + #[test] + fn manifest_assets_are_static_ttf_with_real_weights() { + for entry in manifest() { + for asset in [entry.regular, entry.bold] { + let name = asset + .archive_asset + .unwrap_or_else(|| asset.source.rsplit('/').next().unwrap()); + assert!(name.ends_with(".ttf"), "asset {name} is not a TTF"); + } + assert_eq!(entry.regular.weight, FontWeight::Regular); + assert_eq!(entry.bold.weight, FontWeight::Bold); + } + } + + #[test] + fn manifest_hashes_are_well_formed_and_distinct() { + let mut digests = std::collections::HashSet::new(); + for entry in manifest() { + for asset in [entry.regular, entry.bold] { + assert_ne!(asset.sha256, [0u8; 32]); + assert!(digests.insert(asset.sha256), "duplicate digest in manifest"); + } + } + } + + #[test] + fn manifest_records_a_licence_for_every_typeface() { + for entry in manifest() { + assert_eq!(entry.licence.name, "OFL-1.1"); + assert!(!entry.licence.notice.is_empty()); + assert!(entry.licence.source.starts_with("https://")); + } + } + + #[test] + fn ensure_fetches_verifies_and_caches_once() { + let directory = temporary_directory("font-delivery"); + let fetcher = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Ok(REGULAR_BYTES.to_vec()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Ok(BOLD_BYTES.to_vec()), + ), + ])); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, fetcher.clone(), &entries); + + let assets = delivery.ensure(Typeface::GeistMono).unwrap(); + assert_eq!(assets.regular, REGULAR_BYTES); + assert_eq!(assets.bold, BOLD_BYTES); + assert_eq!(fetcher.calls(), 2); + + // The second ensure reads the verified cache: no new fetches. + assert_eq!(delivery.ensure(Typeface::GeistMono).unwrap(), assets); + assert_eq!(fetcher.calls(), 2); + + // The cache holds exactly the verified TTF files, with no leftovers. + let mut files: Vec = fs::read_dir(&directory) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + files.sort(); + assert_eq!(files, vec!["geist_mono-bold.ttf", "geist_mono-regular.ttf"]); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn corrupt_download_is_rejected_and_not_cached() { + let directory = temporary_directory("font-delivery"); + let fetcher = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Ok(b"corrupted".to_vec()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Ok(b"corrupted".to_vec()), + ), + ])); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, fetcher, &entries); + + let error = delivery.ensure(Typeface::GeistMono).unwrap_err(); + assert!(matches!(error, DeliveryError::Invalid(_))); + assert!(fs::read_dir(&directory).unwrap().next().is_none()); + assert_eq!(delivery.cached(Typeface::GeistMono), None); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn fetch_failure_is_visible_and_retryable() { + let directory = temporary_directory("font-delivery"); + let failing = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Err("offline".to_owned()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Err("offline".to_owned()), + ), + ])); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, failing, &entries); + + let error = delivery.ensure(Typeface::GeistMono).unwrap_err(); + assert!(matches!(error, DeliveryError::Fetch(_))); + assert_eq!(delivery.cached(Typeface::GeistMono), None); + + // The writer stays on the current Typeface; retry recovers offline. + let recovered = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Ok(REGULAR_BYTES.to_vec()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Ok(BOLD_BYTES.to_vec()), + ), + ])); + let delivery = FontDelivery::at(&directory, recovered, &entries); + assert!(delivery.ensure(Typeface::GeistMono).is_ok()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn cleared_or_invalid_cache_is_refetched_and_corrected() { + let directory = temporary_directory("font-delivery"); + let fetcher = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Ok(REGULAR_BYTES.to_vec()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Ok(BOLD_BYTES.to_vec()), + ), + ])); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, fetcher.clone(), &entries); + delivery.ensure(Typeface::GeistMono).unwrap(); + assert_eq!(fetcher.calls(), 2); + + // Cache cleanup is harmless: the next ensure refetches verified bytes. + delivery.clear_cache(Typeface::GeistMono).unwrap(); + assert!(delivery.cached(Typeface::GeistMono).is_none()); + delivery.ensure(Typeface::GeistMono).unwrap(); + assert_eq!(fetcher.calls(), 4); + + // A stale or corrupted cache entry is treated as missing and repaired. + fs::write(directory.join("geist_mono-regular.ttf"), b"stale bytes").unwrap(); + delivery.ensure(Typeface::GeistMono).unwrap(); + assert_eq!(fetcher.calls(), 5); + assert_eq!( + fs::read(directory.join("geist_mono-regular.ttf")).unwrap(), + REGULAR_BYTES + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn missing_cache_reports_cleanly() { + let directory = temporary_directory("font-delivery"); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, FakeFetcher::new(HashMap::new()), &entries); + + assert_eq!(delivery.cached(Typeface::GeistMono), None); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn archive_assets_share_one_download() { + let directory = temporary_directory("font-delivery"); + let archive = zip_with(&[ + ("ttf/FiraCode-Regular.ttf", REGULAR_BYTES), + ("ttf/FiraCode-Bold.ttf", BOLD_BYTES), + ]); + let mut entry = test_entry(); + entry.typeface = Typeface::FiraCode; + let zip_url = "https://example.invalid/FiraCode.zip"; + entry.regular.source = zip_url; + entry.bold.source = zip_url; + entry.regular.archive_asset = Some("ttf/FiraCode-Regular.ttf"); + entry.bold.archive_asset = Some("ttf/FiraCode-Bold.ttf"); + let fetcher = FakeFetcher::new(HashMap::from([(zip_url.to_owned(), Ok(archive))])); + let entries = [entry]; + let delivery = FontDelivery::at(&directory, fetcher.clone(), &entries); + + let assets = delivery.ensure(Typeface::FiraCode).unwrap(); + assert_eq!(assets.regular, REGULAR_BYTES); + assert_eq!(assets.bold, BOLD_BYTES); + assert_eq!( + fetcher.calls(), + 1, + "the shared archive must be downloaded once" + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn missing_archive_entry_fails_cleanly_and_keeps_the_successful_asset() { + let directory = temporary_directory("font-delivery"); + let archive = zip_with(&[("ttf/FiraCode-Regular.ttf", REGULAR_BYTES)]); + let mut entry = test_entry(); + entry.typeface = Typeface::FiraCode; + let zip_url = "https://example.invalid/FiraCode.zip"; + entry.regular.source = zip_url; + entry.bold.source = zip_url; + entry.regular.archive_asset = Some("ttf/FiraCode-Regular.ttf"); + entry.bold.archive_asset = Some("ttf/FiraCode-Bold.ttf"); + let fetcher = FakeFetcher::new(HashMap::from([(zip_url.to_owned(), Ok(archive))])); + let entries = [entry]; + let delivery = FontDelivery::at(&directory, fetcher, &entries); + + // Only the Regular asset is present in the archive; Bold fails its + // extraction, so the Regular bytes are still extracted and cached. + let error = delivery.ensure(Typeface::FiraCode).unwrap_err(); + assert!(matches!(error, DeliveryError::Invalid(_))); + assert_eq!( + fs::read(directory.join("fira_code-regular.ttf")).unwrap(), + REGULAR_BYTES + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn cache_lives_under_the_platform_cache_directory() { + let expected = dirs::cache_dir().map(|directory| { + directory + .join(crate::preferences::CONFIG_DIR_NAME) + .join("fonts") + }); + assert_eq!(default_cache_directory(), expected); + } + + #[test] + fn system_monospace_is_not_downloadable() { + let directory = temporary_directory("font-delivery"); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, FakeFetcher::new(HashMap::new()), &entries); + + let error = delivery.ensure(Typeface::System).unwrap_err(); + assert_eq!(error, DeliveryError::NotDownloadable); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn partial_failure_keeps_the_successful_asset_cached() { + let directory = temporary_directory("font-delivery"); + let fetcher = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Ok(REGULAR_BYTES.to_vec()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Err("offline".to_owned()), + ), + ])); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, fetcher, &entries); + + let error = delivery.ensure(Typeface::GeistMono).unwrap_err(); + assert!(matches!(error, DeliveryError::Fetch(_))); + // The verified Regular asset is cached; the full set is not yet ready. + assert_eq!( + fs::read(directory.join("geist_mono-regular.ttf")).unwrap(), + REGULAR_BYTES + ); + assert_eq!(delivery.cached(Typeface::GeistMono), None); + fs::remove_dir_all(directory).unwrap(); + } + + /// Dev-time verification of the manifest pins: downloads every pinned + /// asset through the production fetcher and checks its SHA-256. Ignored + /// by default because it contacts the public font hosts; run explicitly + /// after changing the manifest. + #[test] + #[ignore] + fn dev_verify_manifest_pins_against_canonical_sources() { + let directory = temporary_directory("font-delivery-verify"); + let delivery = FontDelivery::at(&directory, HttpFetcher, manifest()); + for entry in manifest() { + delivery + .ensure(entry.typeface) + .unwrap_or_else(|error| panic!("{:?}: {error}", entry.typeface)); + delivery.clear_cache(entry.typeface).unwrap(); + } + fs::remove_dir_all(directory).unwrap(); + } + + fn zip_with(entries: &[(&str, &[u8])]) -> Vec { + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + for (name, bytes) in entries { + writer + .start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(bytes).unwrap(); + } + writer.finish().unwrap().into_inner() + } +} diff --git a/src/lib.rs b/src/lib.rs index 47d57bf..c0dae29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub(crate) mod atomic; pub mod autosave; pub mod document_store; +pub mod font_delivery; pub mod persistence; pub mod preferences; pub mod selector; From 968ff7c7f80214379a017b44f4b01eb4478bf948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Mu=C3=B1oz?= Date: Wed, 5 Aug 2026 14:55:21 +0200 Subject: [PATCH 2/4] docs: record manifest TTF sources for phase 4.3 delivery --- docs/font-delivery-research.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/font-delivery-research.md b/docs/font-delivery-research.md index 2fdbbc7..301bd09 100644 --- a/docs/font-delivery-research.md +++ b/docs/font-delivery-research.md @@ -36,6 +36,10 @@ A font downloaded from its official URL and held only in a user's Lettera-local | Source Code Pro | Editor | [Adobe TTF release asset](https://github.com/adobe-fonts/source-code-pro/releases/download/2.042R-u/1.062R-i/1.026R-vf/TTF-source-code-pro-2.042R-u_1.062R-i.zip) | `2.042R-u/1.062R-i/1.026R-vf` | | iA Writer Quattro | UI | [iA official source](https://github.com/iaolo/iA-Fonts/tree/f32c04c3058a75d7ce28919ce70fe8800817491b/iA%20Writer%20Quattro) | commit `f32c04c3058a75d7ce28919ce70fe8800817491b` | +### Manifest sources (Phase 4.3) + +The FontDelivery manifest (`src/font_delivery.rs`) serves the static TTFs directly from the upstream repositories at the same immutable pins as the candidate table above, instead of the release ZIP assets, for Geist Mono, JetBrains Mono, and Source Code Pro. These are the same canonical upstream files at the same pinned revisions; the manifest hashes were captured from them on 2026-08-05. Fira Code publishes no TTF in its release tree, so it remains pinned to its official release archive; iA Writer Mono is served from its pinned commit as recorded. Serving raw tree files avoids downloading and extracting release archives where a static TTF exists at the pin. + ## Spike verification (Phase 4.1) > Date: 2026-08-05, branch `feat/phase-4.1-font-spike`, Arch Linux. From b468428da19e77d53d165d817be9ca737f95a564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Mu=C3=B1oz?= Date: Thu, 6 Aug 2026 08:18:45 +0200 Subject: [PATCH 3/4] feat: bundle iA Writer Quattro as interface typeface --- CHANGELOG.md | 1 + assets/fonts/iA-Writer-Quattro-LICENSE.md | 100 ++++++++++++++++++++++ assets/fonts/iAWriterQuattroV.ttf | Bin 0 -> 163744 bytes docs/font-delivery-research.md | 12 ++- docs/phase-4-settings-and-appearance.md | 2 +- src/editor.rs | 27 +++--- src/fonts.rs | 85 ++++++++++++++++++ src/main.rs | 1 + src/shell.rs | 39 +++++---- 9 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 assets/fonts/iA-Writer-Quattro-LICENSE.md create mode 100644 assets/fonts/iAWriterQuattroV.ttf diff --git a/CHANGELOG.md b/CHANGELOG.md index a924fde..0f478b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Ctrl+N (Cmd+N on macOS) opens a new Draft; the current Document is flushed first and stays active when saving fails or a conflict is unresolved. - Ctrl+P (Cmd+P on macOS) opens a temporary centered Document selector over the Writing surface, composed by a new application shell; `Escape` or a pointer click on the overlay dismisses it without forcing a save or changing the active Document, and the existing flush-or-block New Draft transition is available from inside the selector. - The Document selector catalogs direct-child `.txt` Files as ordered cards with previews, active-buffer overlays, and duplicate-title filename disambiguation. +- The interface uses the bundled iA Writer Quattro variable Typeface from the first frame, so selector and feedback chrome share a stable interface typography without any download. - Minimal Floem application scaffold for editor-focused development. - Standalone local persistence boundary for UTF-8 plain-text Documents with atomic File writes. - Debounced autosave with synchronous shutdown flush and retryable save feedback. diff --git a/assets/fonts/iA-Writer-Quattro-LICENSE.md b/assets/fonts/iA-Writer-Quattro-LICENSE.md new file mode 100644 index 0000000..5cd41aa --- /dev/null +++ b/assets/fonts/iA-Writer-Quattro-LICENSE.md @@ -0,0 +1,100 @@ +# iA Writer Typeface + +Copyright © 2018 Information Architects Inc. with Reserved Font Name "iA Writer" + +# Based on IBM Plex Typeface + +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +# License + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/fonts/iAWriterQuattroV.ttf b/assets/fonts/iAWriterQuattroV.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6d50c141945b0869e9f91a66c06aed691a32f4eb GIT binary patch literal 163744 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 literal 0 HcmV?d00001 diff --git a/docs/font-delivery-research.md b/docs/font-delivery-research.md index 301bd09..4e21260 100644 --- a/docs/font-delivery-research.md +++ b/docs/font-delivery-research.md @@ -57,7 +57,17 @@ Remapping `fontdb`'s generic family with `set_monospace_family` makes direct `db `floem::text::FONT_SYSTEM.lock().db_mut().load_font_data(bytes)` accepted real TTF bytes from disk, grew the face count (973 → 974), and the newly registered face resolved by its own family name afterwards; `db_mut()` clears cosmic-text's font-match cache. Real-weight matching selects distinct faces (e.g. GeistMono Nerd Font Mono regular vs bold IDs differ; Adwaita Mono regular 4 vs bold 1), so no synthetic bold is needed when separate Regular and Bold faces exist. -### Not verified in this spike +#### Bundled Interface typeface: variable over static (Phase 4.4) + +Phase 4.4 bundles iA Writer Quattro for the interface. The original plan was the static Regular/Bold pair (`iAWriterQuattroS-Regular.ttf` / `-Bold.ttf`), but verification showed those two faces cannot express weight in this stack: + +- Both static faces report the same `usWeightClass` (608 in the OS/2 table; fontdb normalises both to weight 400). fontdb queries for `Weight::NORMAL` and `Weight::BOLD` return the **same face id**, so interface bold would render as regular. +- The static files do carry distinct `fsSelection` bits (Regular 64, Bold 32), but fontdb/ttf-parser derive weight from `usWeightClass` only. +- Modifying the font bytes to fix the weight class would be an OFL modification under iA's Reserved Font Names — not acceptable. + +Resolution: bundle the **variable font** `iAWriterQuattroV.ttf` (family `iA Writer Quattro V`, `wght` axis 400–700, default 700) instead of the static pair. Verified by shaping: normal shapes at 91.2 px vs bold at 92.7 px for the same 16px sample — cosmic-text applies the `wght` variation per requested weight. This is a recorded deviation from the phase-4 spec text (“static Regular and Bold are bundled”); the variable file is still a single pinned upstream TTF asset under ADR 0006. + +## Not verified in this spike - macOS and Windows: OS monospace resolution is a stub returning `None`; the production adapter must implement and record per-platform evidence (e.g. `NSFont` monospaced system font on macOS). - Full visual editor relayout and caret preservation after a live Typeface change: the shaping-level mechanism is verified above, but widget-level reflow, scroll, and caret behavior remain a manual check on each platform. diff --git a/docs/phase-4-settings-and-appearance.md b/docs/phase-4-settings-and-appearance.md index 2fefe97..002d447 100644 --- a/docs/phase-4-settings-and-appearance.md +++ b/docs/phase-4-settings-and-appearance.md @@ -79,7 +79,7 @@ Appearance provides curated Themes, Writing width, Letter spacing, Typeface, Fon - Theme mode has System, Paper, and Ink values. System resolves separately mapped light and dark Themes, defaults to Paper and Ink respectively, and listens for supported operating-system appearance changes. Mapping controls stay hidden until a choice has more than one valid Theme. - Writing width remains the existing Narrow, Comfortable, Wide, and Unlimited set. Unlimited keeps 48px horizontal padding per side. Letter spacing remains the existing five discrete values. Font size is limited to 12px through 24px in 1px increments, defaulting to 16px. - A Typeface is either System monospace or one of the five curated editor Typefaces: Geist Mono, JetBrains Mono, iA Writer Mono, Fira Code, and Source Code Pro. System monospace means the OS-configured monospaced Typeface, never an accidental renderer generic fallback. -- iA Writer Quattro static Regular and Bold are bundled as the Interface typeface. Its required copyright and OFL notice ship with the distribution. It is never exposed as a Writing-surface Preference. +- iA Writer Quattro is bundled as the Interface typeface. The variable font (`iA Writer Quattro V`) is used instead of the static Regular/Bold pair because the static faces share one fontdb weight and cannot express interface bold; weight variation on the `wght` axis is verified by shaping (see the font-delivery research note). Its required copyright and OFL notice ship with the distribution. It is never exposed as a Writing-surface Preference. - Curated editor Typefaces are downloaded only after user selection. They are cached in Lettera's platform cache directory, not globally installed, and loaded from verified bytes into Lettera's process-local font database. - FontDelivery owns the fixed manifest, app-local cache, download, hash validation, licence metadata, cache invalidation, and retry result. The manifest uses only canonical upstream sources, pinned release versions or commits, static TTF assets, SHA-256 hashes, and licence records. It does not follow mutable branches or silently update fonts. - A cache miss or invalid entry keeps writing available in the current Typeface or System monospace, retains the desired Typeface Preference, requests a background restore when allowed, and presents retryable Settings feedback on failure. Download success triggers live use only after verification. diff --git a/src/editor.rs b/src/editor.rs index 8dae633..8c07f79 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -29,13 +29,20 @@ use floem::views::editor::{ id::EditorId, view::cursor_caret, CurrentLineColor, Editor, EditorStyle, SelectionColor, }; use floem::views::scroll::ScrollCustomStyle; -use floem::views::{button, container, dyn_container, empty, h_stack, static_label}; +use floem::views::{button, container, dyn_container, empty, h_stack, static_label, Label}; const DEFAULT_FONT_SIZE: f64 = 16.0; const REFERENCE_SAMPLE: &str = "0000000000"; const HORIZONTAL_PADDING: f64 = 48.0; const VERTICAL_PADDING: f64 = 40.0; +/// A static label styled with the bundled Interface typeface. Every +/// non-editor surface (selector, feedback, Settings) uses this so Lettera's +/// interface typography is consistent without touching editor content fonts. +pub(crate) fn interface_label(text: impl Into) -> Label { + static_label(text).style(|s| s.font_family(crate::fonts::INTERFACE_TYPEFACE_FAMILY.to_owned())) +} + pub(crate) type SharedPersistence = Rc>; /// Mutable hook the writing surface calls when a shortcut fires. @@ -747,8 +754,8 @@ pub(crate) fn writing_surface( let session = Rc::clone(&overwrite_session); let feedback = overwrite_feedback; h_stack(( - static_label("Overwrite external changes?"), - button("Confirm").action(move || { + interface_label("Overwrite external changes?"), + button(interface_label("Confirm")).action(move || { let resolved = session.borrow_mut().resolve_keep_local(); match resolved { Ok(()) => { @@ -758,14 +765,14 @@ pub(crate) fn writing_surface( Err(error) => feedback.set(Some(error)), } }), - button("Cancel").action(move || { + button(interface_label("Cancel")).action(move || { confirm_overwrite.set(false); }), )) .into_any() } else { let confirm_overwrite = confirm_overwrite; - button("Overwrite") + button(interface_label("Overwrite")) .action(move || confirm_overwrite.set(true)) .into_any() } @@ -779,9 +786,9 @@ pub(crate) fn writing_surface( let save_copy_feedback = feedback; h_stack(( - static_label(message), + interface_label(message), overwrite_confirmation, - button("Load external").action(move || { + button(interface_label("Load external")).action(move || { let resolved = load_session.borrow_mut().resolve_load_external(); match resolved { Ok(Some(content)) => { @@ -798,7 +805,7 @@ pub(crate) fn writing_surface( Err(error) => load_feedback.set(Some(error)), } }), - button("Save copy").action(move || { + button(interface_label("Save copy")).action(move || { let resolved = save_copy_session.borrow_mut().resolve_save_copy(); match resolved { Ok(()) => { @@ -813,8 +820,8 @@ pub(crate) fn writing_surface( .into_any() } else { h_stack(( - static_label(message), - button("Retry").action(move || { + interface_label(message), + button(interface_label("Retry")).action(move || { let request = session.borrow_mut().begin_retry(); if let Some(request) = request { start_background_save(Rc::clone(&session), feedback, request, None); diff --git a/src/fonts.rs b/src/fonts.rs index e69bb38..1a511a5 100644 --- a/src/fonts.rs +++ b/src/fonts.rs @@ -22,6 +22,27 @@ //! `docs/font-delivery-research.md` until Phase 4.5 hardens the adapter. //! Nothing in the application calls this yet. +use floem::text::FONT_SYSTEM; + +/// Family name of the bundled Interface typeface (iA Writer Quattro variable +/// font). Weight variation on the `wght` axis is verified by the +/// `bundled_interface_typeface_shapes_distinct_weights` test. +pub const INTERFACE_TYPEFACE_FAMILY: &str = "iA Writer Quattro V"; + +const INTERFACE_TYPEFACE: &[u8] = include_bytes!("../assets/fonts/iAWriterQuattroV.ttf"); + +/// Register the bundled Interface typeface into the process font database so +/// interface text can use it from the first frame. +/// +/// Failure is non-fatal: interface text falls back to the renderer default. +/// The bundled licence notice ships at `assets/fonts/iA-Writer-Quattro-LICENSE.md`. +pub fn register_bundled_interface_typeface() { + FONT_SYSTEM + .lock() + .db_mut() + .load_font_data(INTERFACE_TYPEFACE.to_vec()); +} + /// Resolve the monospaced Typeface the operating system has configured for /// ordinary local applications. /// @@ -388,4 +409,68 @@ mod tests { "shaping must follow the explicitly named family" ); } + + #[test] + fn bundled_interface_typeface_registers_and_resolves() { + use floem::text::{fontdb, Family, Stretch, Style, Weight}; + let mut fresh = fontdb::Database::new(); + fresh.load_font_data(INTERFACE_TYPEFACE.to_vec()); + + let resolved = fresh.query(&fontdb::Query { + families: &[Family::Name(INTERFACE_TYPEFACE_FAMILY)], + weight: Weight::NORMAL, + stretch: Stretch::Normal, + style: Style::Normal, + }); + assert!( + resolved.is_some(), + "bundled Interface typeface must resolve from its bytes alone" + ); + let face = fresh + .face(resolved.expect("checked above")) + .expect("face exists"); + let family = face + .families + .first() + .map(|(name, _)| name.clone()) + .expect("family name"); + assert_eq!( + family, INTERFACE_TYPEFACE_FAMILY, + "resolved family must match the Interface typeface constant" + ); + } + + /// iA's static Regular/Bold faces both report the same fontdb weight, so + /// the pair cannot express interface bold. The variable font's `wght` + /// axis is verified here by shaping: bold must produce different metrics + /// than normal for the same family. + #[test] + fn bundled_interface_typeface_shapes_distinct_weights() { + use floem::text::{Attrs, AttrsList, FamilyOwned, TextLayout, Weight}; + FONT_SYSTEM + .lock() + .db_mut() + .load_font_data(INTERFACE_TYPEFACE.to_vec()); + + let fam = [FamilyOwned::Name(INTERFACE_TYPEFACE_FAMILY.to_owned())]; + let width = |weight| { + let mut layout = TextLayout::new(); + layout.set_text( + "Hello World", + AttrsList::new(Attrs::new().family(&fam).weight(weight).font_size(16.0)), + ); + layout.size().width + }; + let normal = width(Weight::NORMAL); + let bold = width(Weight::BOLD); + eprintln!( + "EVIDENCE interface-weights normal={normal} bold={bold} family={INTERFACE_TYPEFACE_FAMILY}" + ); + // 0.1 px is far beyond shaping noise for a 16px proportional sample + // and small enough to catch any regression where bold stops applying. + assert!( + (normal - bold).abs() > 0.1, + "interface bold must shape with heavier glyphs than normal" + ); + } } diff --git a/src/main.rs b/src/main.rs index 1acf672..98219f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod fonts; mod shell; fn main() { + fonts::register_bundled_interface_typeface(); let smoke_test = std::env::args().any(|arg| arg == "--smoke-test"); if smoke_test { floem::launch(shell::smoke_test_view); diff --git a/src/shell.rs b/src/shell.rs index 5768ba2..b6d4e86 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -27,9 +27,9 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use std::thread; use crate::editor::{ - active_palette, replace_editor_content, start_background_save, switch_to_new_draft, - sync_autosave_feedback, writing_surface, LetterSpacing, SurfaceHandles, ThemePalette, - WritingWidth, + active_palette, interface_label, replace_editor_content, start_background_save, + switch_to_new_draft, sync_autosave_feedback, writing_surface, LetterSpacing, SurfaceHandles, + ThemePalette, WritingWidth, }; type SharedPersistence = Rc>; @@ -77,20 +77,20 @@ fn selector_card( let filename = entry.display_filename().map(ToOwned::to_owned); let active = entry.is_active(); container(v_stack(( - static_label(title).style(move |s| { + interface_label(title).style(move |s| { s.font_size(17.0) .font_bold() .height(42.0) .text_clip() .color(palette.text) }), - static_label(preview).style(move |s| { + interface_label(preview).style(move |s| { s.font_size(13.0) .height(38.0) .text_clip() .color(palette.text) }), - static_label(if selected && active { + interface_label(if selected && active { "Selected · Active" } else if selected { "Selected" @@ -101,8 +101,8 @@ fn selector_card( }) .style(move |s| s.font_size(11.0).color(palette.selection)), h_stack(( - static_label(date).style(move |s| s.font_size(11.0).color(palette.text)), - static_label(filename.unwrap_or_default()) + interface_label(date).style(move |s| s.font_size(11.0).color(palette.text)), + interface_label(filename.unwrap_or_default()) .style(move |s| s.font_size(11.0).color(palette.text)), )) .style(|s| s.justify_between().width_pct(100.0)), @@ -145,23 +145,23 @@ fn selector_content( let retry_for_error_view = Rc::clone(&retry); let error_view = match selector_error { Some(error) => h_stack(( - static_label(error).style(move |s| s.font_size(12.0).color(palette.selection)), - button("Retry").action(move || retry_for_error_view()), + interface_label(error).style(move |s| s.font_size(12.0).color(palette.selection)), + button(interface_label("Retry")).action(move || retry_for_error_view()), )) .into_any(), None => floem::views::empty().into_any(), }; match state { CatalogState::Loading => v_stack(( - static_label("Loading Documents…").style(move |s| s.color(palette.text)), - static_label("Scanning saved Files in background…") + interface_label("Loading Documents…").style(move |s| s.color(palette.text)), + interface_label("Scanning saved Files in background…") .style(move |s| s.font_size(13.0).color(palette.text)), error_view, )), CatalogState::Error(error) => v_stack(( - static_label("Could not load Documents").style(move |s| s.color(palette.text)), - static_label(error).style(move |s| s.font_size(13.0).color(palette.text)), - button("Retry") + interface_label("Could not load Documents").style(move |s| s.color(palette.text)), + interface_label(error).style(move |s| s.font_size(13.0).color(palette.text)), + button(interface_label("Retry")) .style(move |s| { s.padding(8.0) .background(palette.selection) @@ -185,8 +185,8 @@ fn selector_content( "" }; v_stack(( - static_label(warning).style(move |s| s.font_size(12.0).color(palette.selection)), - static_label(empty).style(move |s| s.font_size(15.0).color(palette.text)), + interface_label(warning).style(move |s| s.font_size(12.0).color(palette.selection)), + interface_label(empty).style(move |s| s.font_size(15.0).color(palette.text)), error_view, scroll(v_stack_from_iter(entries.chunks(columns.max(1)).map( |row| { @@ -225,6 +225,7 @@ fn build_selector_modal( .padding(8.0) .border(1.0) .border_color(palette.border) + .font_family(crate::fonts::INTERFACE_TYPEFACE_FAMILY.to_owned()) }); let search_id = search.id(); create_effect(move |_| { @@ -270,11 +271,11 @@ fn build_selector_modal( }, ); let view = container(v_stack(( - static_label("Open document") + interface_label("Open document") .style(move |s| s.font_size(20.0).color(palette.text).padding_bottom(8.0)), search, content, - button("New Draft") + button(interface_label("New Draft")) .style(move |s| { s.margin_top(12.0) .padding_horiz(16.0) From 728f5f9e0946b58a0adb95deb4784c2de59fa686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Mu=C3=B1oz?= Date: Thu, 6 Aug 2026 08:25:09 +0200 Subject: [PATCH 4/4] fix: import interface consts ungated for cross-platform tests --- src/fonts.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/fonts.rs b/src/fonts.rs index 1a511a5..0804fe3 100644 --- a/src/fonts.rs +++ b/src/fonts.rs @@ -95,6 +95,9 @@ mod tests { use floem::text::{Attrs, AttrsList, Family, FamilyOwned, TextLayout, FONT_SYSTEM}; #[cfg(target_os = "linux")] use std::collections::HashMap; + // Interface typeface consts are used by the two cross-platform tests + // (bundled bytes register everywhere), so they are imported ungated. + use super::{INTERFACE_TYPEFACE, INTERFACE_TYPEFACE_FAMILY}; /// Monospace glyph sample wide enough to expose metric differences. const SAMPLE: &str = "iiiiiiiiii";