winbrew_windows\font/
mod.rs1use 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
15pub 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
26pub 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(®istry_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
73pub 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(®istry_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}