winbrew_windows\font/
mod.rs

1use anyhow::{Context, Result, bail};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5mod user_fonts;
6
7use self::user_fonts::{register_user_font_value, unregister_user_font_value};
8use windows_sys::Win32::Graphics::Gdi::{
9    AddFontResourceExW, FR_NOT_ENUM, FR_PRIVATE, RemoveFontResourceExW,
10};
11
12const FONT_RESOURCE_FLAGS: u32 = FR_PRIVATE | FR_NOT_ENUM;
13const SUPPORTED_FONT_EXTENSIONS: &[&str] = &["ttf", "otf", "ttc", "otc"];
14
15/// Return the per-user Windows font directory.
16pub fn user_fonts_dir() -> Result<PathBuf> {
17    let local_app_data = std::env::var_os("LOCALAPPDATA")
18        .context("LOCALAPPDATA is not set on this Windows session")?;
19
20    Ok(PathBuf::from(local_app_data)
21        .join("Microsoft")
22        .join("Windows")
23        .join("Fonts"))
24}
25
26/// Install a raw font file into the per-user Windows font directory.
27pub fn install_user_font(source_path: &Path) -> Result<PathBuf> {
28    validate_font_source(source_path)?;
29
30    let fonts_dir = user_fonts_dir()?;
31    fs::create_dir_all(&fonts_dir).with_context(|| {
32        format!(
33            "failed to create user font directory at {}",
34            fonts_dir.display()
35        )
36    })?;
37
38    let file_name = source_path
39        .file_name()
40        .context("font source path does not have a file name")?;
41    let destination = fonts_dir.join(file_name);
42
43    fs::copy(source_path, &destination).with_context(|| {
44        format!(
45            "failed to copy font from {} to {}",
46            source_path.display(),
47            destination.display()
48        )
49    })?;
50
51    let registry_value_name = register_user_font(&destination).with_context(|| {
52        format!(
53            "failed to register font '{}' in the Windows registry",
54            destination.display()
55        )
56    })?;
57
58    if let Err(err) = add_font_resource(&destination) {
59        let _ = unregister_user_font_by_name(&registry_value_name);
60        let _ = fs::remove_file(&destination);
61
62        return Err(err).with_context(|| {
63            format!(
64                "failed to load font '{}' into the current Windows session",
65                destination.display()
66            )
67        });
68    }
69
70    Ok(destination)
71}
72
73/// Remove a font file from the per-user Windows font directory.
74pub fn remove_user_font(installed_path: &Path) -> Result<()> {
75    if installed_path.as_os_str().is_empty() {
76        bail!("installed font path cannot be empty");
77    }
78
79    let registry_value_name = font_registry_value_name(installed_path)?;
80
81    let _ = remove_font_resource(installed_path);
82
83    unregister_user_font_by_name(&registry_value_name).with_context(|| {
84        format!(
85            "failed to unregister font '{}' from the Windows registry",
86            installed_path.display()
87        )
88    })?;
89
90    match fs::remove_file(installed_path) {
91        Ok(()) => Ok(()),
92        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
93        Err(err) => Err(err)
94            .with_context(|| format!("failed to remove font at {}", installed_path.display())),
95    }
96}
97
98fn register_user_font(font_path: &Path) -> Result<String> {
99    let value_name = font_registry_value_name(font_path)?;
100    let value_data = font_path.to_string_lossy().to_string();
101
102    register_user_font_value(&value_name, &value_data)?;
103
104    Ok(value_name)
105}
106
107fn unregister_user_font_by_name(value_name: &str) -> Result<()> {
108    unregister_user_font_value(value_name)
109}
110
111fn font_registry_value_name(font_path: &Path) -> Result<String> {
112    let file_stem = font_path
113        .file_stem()
114        .context("font path does not have a file stem")?
115        .to_string_lossy()
116        .trim()
117        .to_string();
118
119    if file_stem.is_empty() {
120        bail!("font path file stem cannot be empty");
121    }
122
123    let extension = font_path
124        .extension()
125        .and_then(|value| value.to_str())
126        .map(|value| value.to_ascii_lowercase())
127        .unwrap_or_default();
128
129    let Some(suffix) = font_value_suffix(&extension) else {
130        bail!(
131            "unsupported font extension for {}: expected one of .ttf, .otf, .ttc, or .otc",
132            font_path.display()
133        );
134    };
135
136    Ok(format!("{file_stem}{suffix}"))
137}
138
139fn font_value_suffix(extension: &str) -> Option<&'static str> {
140    match extension {
141        "ttf" | "ttc" => Some(" (TrueType)"),
142        "otf" | "otc" => Some(" (OpenType)"),
143        _ => None,
144    }
145}
146
147fn add_font_resource(font_path: &Path) -> Result<()> {
148    let wide_path = wide_path(font_path);
149
150    let added =
151        unsafe { AddFontResourceExW(wide_path.as_ptr(), FONT_RESOURCE_FLAGS, std::ptr::null()) };
152
153    if added == 0 {
154        bail!("AddFontResourceExW failed for {}", font_path.display());
155    }
156
157    Ok(())
158}
159
160fn remove_font_resource(font_path: &Path) -> Result<()> {
161    let wide_path = wide_path(font_path);
162
163    let removed =
164        unsafe { RemoveFontResourceExW(wide_path.as_ptr(), FONT_RESOURCE_FLAGS, std::ptr::null()) };
165
166    if removed == 0 {
167        return Ok(());
168    }
169
170    Ok(())
171}
172
173fn wide_path(path: &Path) -> Vec<u16> {
174    use std::os::windows::ffi::OsStrExt;
175
176    let mut wide_path: Vec<u16> = path.as_os_str().encode_wide().collect();
177    wide_path.push(0);
178    wide_path
179}
180
181fn validate_font_source(source_path: &Path) -> Result<()> {
182    if source_path.as_os_str().is_empty() {
183        bail!("font source path cannot be empty");
184    }
185
186    if !source_path.exists() {
187        bail!("font source path does not exist: {}", source_path.display());
188    }
189
190    if !source_path.is_file() {
191        bail!("font source path is not a file: {}", source_path.display());
192    }
193
194    let extension = source_path
195        .extension()
196        .and_then(|value| value.to_str())
197        .map(|value| value.to_ascii_lowercase())
198        .unwrap_or_default();
199
200    if !SUPPORTED_FONT_EXTENSIONS.contains(&extension.as_str()) {
201        bail!(
202            "unsupported font extension for {}: expected one of .ttf, .otf, .ttc, or .otc",
203            source_path.display()
204        );
205    }
206
207    Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212    use super::user_fonts::USER_FONTS_REGISTRY_PATH;
213    use super::{
214        FONT_RESOURCE_FLAGS, font_registry_value_name, install_user_font, register_user_font,
215        remove_user_font, unregister_user_font_by_name, validate_font_source,
216    };
217    use std::fs;
218    use std::path::PathBuf;
219    use std::time::{SystemTime, UNIX_EPOCH};
220    use windows_sys::Win32::Graphics::Gdi::{FR_NOT_ENUM, FR_PRIVATE};
221    use winreg::{RegKey, enums::HKEY_CURRENT_USER};
222
223    fn temp_font_path(name: &str) -> PathBuf {
224        std::env::temp_dir().join(format!(
225            "winbrew-font-test-{}-{}-{name}",
226            std::process::id(),
227            SystemTime::now()
228                .duration_since(UNIX_EPOCH)
229                .expect("time should be monotonic")
230                .as_nanos()
231        ))
232    }
233
234    #[test]
235    fn font_registry_value_name_uses_expected_suffixes() {
236        assert_eq!(
237            font_registry_value_name(&PathBuf::from(r"C:\Fonts\Demo.ttf"))
238                .expect("ttf should parse"),
239            "Demo (TrueType)"
240        );
241        assert_eq!(
242            font_registry_value_name(&PathBuf::from(r"C:\Fonts\Demo.otf"))
243                .expect("otf should parse"),
244            "Demo (OpenType)"
245        );
246        assert_eq!(
247            font_registry_value_name(&PathBuf::from(r"C:\Fonts\Demo.ttc"))
248                .expect("ttc should parse"),
249            "Demo (TrueType)"
250        );
251        assert_eq!(
252            font_registry_value_name(&PathBuf::from(r"C:\Fonts\Demo.otc"))
253                .expect("otc should parse"),
254            "Demo (OpenType)"
255        );
256    }
257
258    #[test]
259    fn install_user_font_rejects_unsupported_extensions() {
260        let font_path = temp_font_path("unsupported.txt");
261        fs::write(&font_path, b"not a font").expect("write temp file");
262
263        let err = install_user_font(&font_path).expect_err("unsupported font should fail");
264
265        assert!(err.to_string().contains("unsupported font extension"));
266
267        let _ = fs::remove_file(&font_path);
268    }
269
270    #[test]
271    fn install_user_font_rejects_missing_source_file() {
272        let font_path = temp_font_path("missing.ttf");
273
274        let err = install_user_font(&font_path).expect_err("missing font should fail");
275
276        assert!(err.to_string().contains("font source path does not exist"));
277    }
278
279    #[test]
280    fn validate_font_source_rejects_empty_paths_without_a_file_stem() {
281        let err = validate_font_source(&PathBuf::from("")).expect_err("empty path should fail");
282
283        assert!(err.to_string().contains("font source path cannot be empty"));
284
285        let err = font_registry_value_name(&PathBuf::from(""))
286            .expect_err("empty path should not have a file stem");
287
288        assert!(
289            err.to_string()
290                .contains("font path does not have a file stem")
291        );
292    }
293
294    #[test]
295    fn font_resource_flags_include_private_and_not_enum() {
296        assert_eq!(FONT_RESOURCE_FLAGS, FR_PRIVATE | FR_NOT_ENUM);
297    }
298
299    #[test]
300    fn register_and_unregister_user_font_round_trip_registry_value() {
301        let font_path = temp_font_path("registry-round-trip.ttf");
302        fs::write(&font_path, b"dummy font payload").expect("write temp font file");
303
304        let value_name = register_user_font(&font_path).expect("registry entry should be written");
305
306        let fonts_key = RegKey::predef(HKEY_CURRENT_USER)
307            .open_subkey(USER_FONTS_REGISTRY_PATH)
308            .expect("fonts key should exist");
309        let stored_path: String = fonts_key
310            .get_value(&value_name)
311            .expect("registry value should exist");
312
313        assert_eq!(stored_path, font_path.to_string_lossy());
314
315        unregister_user_font_by_name(&value_name).expect("registry entry should be removed");
316
317        let fonts_key = RegKey::predef(HKEY_CURRENT_USER)
318            .open_subkey(USER_FONTS_REGISTRY_PATH)
319            .expect("fonts key should still exist");
320        assert!(fonts_key.get_value::<String, _>(&value_name).is_err());
321
322        let _ = fs::remove_file(&font_path);
323    }
324
325    #[test]
326    fn remove_user_font_cleans_registry_entry_and_file() {
327        let font_path = temp_font_path("remove-round-trip.ttf");
328        fs::write(&font_path, b"dummy font payload").expect("write temp font file");
329
330        let value_name = register_user_font(&font_path).expect("registry entry should be written");
331        assert!(
332            RegKey::predef(HKEY_CURRENT_USER)
333                .open_subkey(USER_FONTS_REGISTRY_PATH)
334                .expect("fonts key should exist")
335                .get_value::<String, _>(&value_name)
336                .is_ok()
337        );
338
339        remove_user_font(&font_path).expect("font removal should succeed");
340
341        assert!(!font_path.exists());
342        let fonts_key = RegKey::predef(HKEY_CURRENT_USER)
343            .open_subkey(USER_FONTS_REGISTRY_PATH)
344            .expect("fonts key should exist");
345        assert!(fonts_key.get_value::<String, _>(&value_name).is_err());
346    }
347
348    #[test]
349    fn remove_user_font_is_idempotent_when_registry_entry_is_missing() {
350        let font_path = temp_font_path("missing-registry.ttf");
351        fs::write(&font_path, b"dummy font payload").expect("write temp font file");
352
353        remove_user_font(&font_path).expect("font removal should succeed without registry entry");
354
355        assert!(!font_path.exists());
356    }
357
358    #[test]
359    fn remove_user_font_is_idempotent_when_font_file_is_missing() {
360        let font_path = temp_font_path("missing-file.ttf");
361        fs::write(&font_path, b"dummy font payload").expect("write temp font file");
362
363        let value_name = register_user_font(&font_path).expect("registry entry should be written");
364        fs::remove_file(&font_path).expect("remove temp font file");
365
366        remove_user_font(&font_path).expect("font removal should succeed without the file");
367
368        let fonts_key = RegKey::predef(HKEY_CURRENT_USER)
369            .open_subkey(USER_FONTS_REGISTRY_PATH)
370            .expect("fonts key should exist");
371        assert!(fonts_key.get_value::<String, _>(&value_name).is_err());
372    }
373}