ohmu
·
2026-10-15
1#include "common/debug.h"
2#include "common/library.h"
3#include "common/io.h"
4#include "common/path.h"
5#include "common/processing.h"
6#include "common/properties.h"
7#include "common/windows/folders.h"
8#include "common/windows/unicode.h"
9#include "common/windows/registry.h"
10#include "common/strutil.h"
11#include "detection/terminalshell/terminalshell.h"
12#include "terminalfont.h"
13
14#include <windows.h>
15#include <stdlib.h>
16
17// Windows Terminal resolves every setting through an inheritance chain. A single property,
18// such as the font face or the font size, is resolved from the most to the least important
19// source:
20// 4. the active profile itself (settings.json -> profiles.list)
21// 3. profiles.defaults (settings.json)
22// 2. a JSON fragment updating the profile with "updates"
23// 1. a JSON fragment defining the profile with "guid"
24// 0. not set -> the builtin default reported below
25// https://github.com/microsoft/terminal/blob/main/src/cascadia/TerminalSettingsModel/IInheritable.h
26// https://github.com/microsoft/terminal/blob/main/src/cascadia/TerminalSettingsModel/CascadiaSettingsSerialization.cpp
27enum {
28 WT_FONT_PRIORITY_FRAGMENT = 1,
29 WT_FONT_PRIORITY_FRAGMENT_UPDATES = 2,
30 WT_FONT_PRIORITY_DEFAULTS = 3,
31 WT_FONT_PRIORITY_PROFILE = 4,
32};
33
34typedef struct FFTerminalFontWT {
35 FFstrbuf name;
36 double size;
37 uint8_t namePriority; // 0 if unset
38 uint8_t sizePriority; // 0 if unset
39} FFTerminalFontWT;
40
41static inline void wrapWTFontFree(FFTerminalFontWT* result) {
42 assert(result);
43 ffStrbufDestroy(&result->name);
44}
45
46static FFTerminalFontWT ffTerminalFontWTCreate(void) {
47 FFTerminalFontWT result = {
48 .name = ffStrbufCreate(),
49 .size = -1,
50 };
51 return result;
52}
53
54static void applyWTProfile(yyjson_val* profile, uint8_t priority, FFTerminalFontWT* result) {
55 yyjson_val* font = yyjson_obj_get(profile, "font");
56 if (!yyjson_is_obj(font)) {
57 return;
58 }
59
60 if (result->namePriority < priority) {
61 yyjson_val* face = yyjson_obj_get(font, "face");
62 if (yyjson_is_str(face)) {
63 ffStrbufClear(&result->name);
64 ffStrbufAppendJsonVal(&result->name, face);
65 if (result->name.length > 0) { // an empty face is treated as unset
66 result->namePriority = priority;
67 }
68 }
69 }
70
71 if (result->sizePriority < priority) {
72 yyjson_val* size = yyjson_obj_get(font, "size");
73 if (yyjson_is_num(size)) {
74 result->size = unsafe_yyjson_get_num(size);
75 result->sizePriority = priority;
76 }
77 }
78}
79
80// Finds the profile matching `wtProfileId` in a `profiles` array.
81// A fragment either defines a profile with "guid" or updates an existing one with "updates",
82// the latter being more important, so it is looked up first.
83// Note that "guid" and "updates" may be missing: yyjson_get_str() returns nullptr then.
84// `fromUpdates` may be null: it is only useful to a caller that ranks the two apart, which a
85// fragment does. settings.json does not -- an "updates" entry and a "guid" entry in profiles.list
86// both sit at the same point of the inheritance chain, and the two passes above are what pick the
87// right one between them.
88static yyjson_val* findWTProfileInArray(yyjson_val* profiles, const FFstrbuf* wtProfileId, bool* fromUpdates) {
89 if (!yyjson_is_arr(profiles)) {
90 return nullptr;
91 }
92
93 for (uint8_t pass = 0; pass < 2; ++pass) {
94 yyjson_val* profile;
95 size_t idx, max;
96 yyjson_arr_foreach (profiles, idx, max, profile) {
97 const char* id = yyjson_get_str(yyjson_obj_get(profile, pass == 0 ? "updates" : "guid"));
98 if (id && ffStrbufEqualS(wtProfileId, id)) {
99 if (fromUpdates) {
100 *fromUpdates = pass == 0;
101 }
102 return profile;
103 }
104 }
105 }
106
107 return nullptr;
108}
109
110static inline void wrapYyjsonFree(yyjson_doc** doc) {
111 assert(doc);
112 if (*doc) {
113 yyjson_doc_free(*doc);
114 }
115}
116
117static const char* detectFromWTSettings(FFstrbuf* content, const FFstrbuf* wtProfileId, FFTerminalFontWT* result) {
118 [[gnu::cleanup(wrapYyjsonFree)]] yyjson_doc* doc = yyjson_read_opts(content->chars, content->length, YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS, nullptr, nullptr);
119 if (!doc) {
120 FF_DEBUG("Failed to parse WT JSON config file");
121 return "Failed to parse WT JSON config file";
122 }
123
124 yyjson_val* const root = yyjson_doc_get_root(doc);
125 assert(root);
126
127 yyjson_val* profiles = yyjson_obj_get(root, "profiles");
128 if (!profiles) {
129 FF_DEBUG("yyjson_obj_get(root, \"profiles\") failed");
130 return "yyjson_obj_get(root, \"profiles\") failed";
131 }
132
133 if (wtProfileId->length > 0) {
134 yyjson_val* profile = findWTProfileInArray(yyjson_obj_get(profiles, "list"), wtProfileId, nullptr);
135 if (profile) {
136 applyWTProfile(profile, WT_FONT_PRIORITY_PROFILE, result);
137 }
138 }
139
140 yyjson_val* defaults = yyjson_obj_get(profiles, "defaults");
141 if (defaults) {
142 applyWTProfile(defaults, WT_FONT_PRIORITY_DEFAULTS, result);
143 }
144
145 return nullptr;
146}
147
148// Windows Terminal reads fragment files from a two level directory layout and doesn't recurse:
149// <known folder>\Microsoft\Windows Terminal\Fragments\<app-name>\<file-name>.json
150// https://learn.microsoft.com/en-us/windows/terminal/json-fragment-extensions#where-to-place-the-json-fragment-files
151static void applyWTFragmentFile(const char* path, const FFstrbuf* wtProfileId, FFTerminalFontWT* result) {
152 FF_STRBUF_AUTO_DESTROY content = ffStrbufCreate();
153 if (!ffReadFileBuffer(path, &content)) {
154 return;
155 }
156
157 // Fragments are optional: an unreadable or malformed one must not fail font detection
158 [[gnu::cleanup(wrapYyjsonFree)]] yyjson_doc* doc = yyjson_read_opts(content.chars, content.length, YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS, nullptr, nullptr);
159 if (!doc) {
160 return;
161 }
162
163 yyjson_val* const root = yyjson_doc_get_root(doc);
164 assert(root);
165
166 bool fromUpdates = false;
167 yyjson_val* profile = findWTProfileInArray(yyjson_obj_get(root, "profiles"), wtProfileId, &fromUpdates);
168 if (profile) {
169 applyWTProfile(profile, fromUpdates ? WT_FONT_PRIORITY_FRAGMENT_UPDATES : WT_FONT_PRIORITY_FRAGMENT, result);
170 }
171}
172
173static void detectWTProfileFromFragmentsIn(const FFstrbuf* fragmentDir, const FFstrbuf* wtProfileId, FFTerminalFontWT* result) {
174 FF_STRBUF_AUTO_DESTROY path = ffStrbufCreateCopy(fragmentDir);
175 const uint32_t baseLength = path.length;
176
177 ffStrbufAppendC(&path, '*');
178 // The wide enumeration, because the directory above came from SHGetKnownFolderPath(): a profile
179 // whose name is not ASCII would otherwise hand FindFirstFileA a UTF-8 path that it reads as ANSI,
180 // and no fragment would be found at all.
181 wchar_t pathW[MAX_PATH];
182 if (!NT_SUCCESS(RtlUTF8ToUnicodeN(pathW, (ULONG) sizeof(pathW), nullptr, path.chars, (ULONG) path.length + 1))) {
183 return;
184 }
185
186 WIN32_FIND_DATAW entry;
187 FF_AUTO_CLOSE_DIR HANDLE hFind = FindFirstFileW(pathW, &entry);
188 if (hFind == INVALID_HANDLE_VALUE) {
189 return;
190 }
191
192 do {
193 if (!(entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || entry.cFileName[0] == L'.') {
194 continue;
195 }
196
197 ffStrbufSubstrBefore(&path, baseLength);
198 ffStrbufAppendWS(&path, entry.cFileName);
199 ffStrbufAppendC(&path, '\\');
200 const uint32_t appLength = path.length;
201
202 ffStrbufAppendS(&path, "*.json");
203 // The path changed, so the pattern has to be converted again. A name that cannot be converted
204 // is skipped rather than failing the whole scan: the next iteration rebuilds `path` from
205 // `baseLength` anyway.
206 if (!NT_SUCCESS(RtlUTF8ToUnicodeN(pathW, (ULONG) sizeof(pathW), nullptr, path.chars, (ULONG) path.length + 1))) {
207 continue;
208 }
209
210 WIN32_FIND_DATAW fileEntry;
211 FF_AUTO_CLOSE_DIR HANDLE hFile = FindFirstFileW(pathW, &fileEntry);
212 if (hFile != INVALID_HANDLE_VALUE) {
213 do {
214 if (fileEntry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
215 continue;
216 }
217
218 ffStrbufSubstrBefore(&path, appLength);
219 ffStrbufAppendWS(&path, fileEntry.cFileName);
220 applyWTFragmentFile(path.chars, wtProfileId, result);
221 } while (FindNextFileW(hFile, &fileEntry));
222 }
223
224 ffStrbufSubstrBefore(&path, baseLength);
225 } while (FindNextFileW(hFind, &entry));
226}
227
228static void detectFromWTFragments(const FFstrbuf* wtProfileId, FFTerminalFontWT* result) {
229 // Windows Terminal merges the user scoped fragments before the machine scoped ones,
230 // so the user scoped fragments are more important
231 static const FFKnownFolder fragmentFolders[] = { FF_KNOWN_FOLDER_LOCAL_APP_DATA, FF_KNOWN_FOLDER_PROGRAM_DATA };
232
233 for (uint32_t i = 0; i < ARRAY_SIZE(fragmentFolders); ++i) {
234 FF_STRBUF_AUTO_DESTROY fragmentDir = ffStrbufCreate();
235 if (ffGetKnownFolderPath(fragmentFolders[i], &fragmentDir)) {
236 ffStrbufAppendS(&fragmentDir, "\\Microsoft\\Windows Terminal\\Fragments\\");
237
238 if (ffPathExists(fragmentDir.chars, FF_PATHTYPE_DIRECTORY)) {
239 detectWTProfileFromFragmentsIn(&fragmentDir, wtProfileId, result);
240 }
241 }
242 }
243}
244
245static void detectFromWindowsTerminal(const FFstrbuf* terminalExe, FFTerminalFontResult* terminalFont) {
246 // https://learn.microsoft.com/en-us/windows/terminal/install#settings-json-file
247 FF_STRBUF_AUTO_DESTROY json = ffStrbufCreate();
248 const char* error = nullptr;
249
250 if (terminalExe && ffIsAbsolutePath(terminalExe->chars)) {
251 FF_STRBUF_AUTO_DESTROY jsonPath = ffStrbufCreateA(MAX_PATH);
252 ffStrbufAppendNS(&jsonPath, ffStrbufLastIndexC(terminalExe, '\\') + 1, terminalExe->chars);
253 ffStrbufAppendS(&jsonPath, ".portable");
254
255 if (ffPathExists(jsonPath.chars, FF_PATHTYPE_ANY)) {
256 ffStrbufSubstrBefore(&jsonPath, jsonPath.length - strlen(".portable"));
257 ffStrbufAppendS(&jsonPath, "settings\\settings.json");
258 if (!ffAppendFileBuffer(jsonPath.chars, &json)) {
259 error = "Error reading Windows Terminal portable settings JSON file";
260 }
261 } else {
262 if (ffGetKnownFolderPath(FF_KNOWN_FOLDER_LOCAL_APP_DATA, &jsonPath)) {
263 if (ffStrbufContainIgnCaseS(terminalExe, "_8wekyb3d8bbwe\\")) {
264 // Microsoft Store version
265 if (ffStrbufContainIgnCaseS(terminalExe, ".WindowsTerminalPreview_")) {
266 // Preview version
267 ffStrbufAppendS(&jsonPath, "\\Packages\\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\\LocalState\\settings.json");
268 if (!ffAppendFileBuffer(jsonPath.chars, &json)) {
269 error = "Error reading Windows Terminal Preview settings JSON file";
270 }
271 } else {
272 // Stable version
273 ffStrbufAppendS(&jsonPath, "\\Packages\\Microsoft.WindowsTerminal_8wekyb3d8bbwe\\LocalState\\settings.json");
274 if (!ffAppendFileBuffer(jsonPath.chars, &json)) {
275 error = "Error reading Windows Terminal settings JSON file";
276 }
277 }
278 } else {
279 ffStrbufAppendS(&jsonPath, "\\Microsoft\\Windows Terminal\\settings.json");
280 if (!ffAppendFileBuffer(jsonPath.chars, &json)) {
281 error = "Error reading Windows Terminal settings JSON file";
282 }
283 }
284 }
285 }
286 }
287
288 if (!error && json.length == 0) {
289 error = ffProcessAppendStdOut(&json, (char* const[]) { "cmd.exe", "/c",
290 // print the file content directly, so we don't need to handle the difference of Windows and POSIX path
291 "if exist %LOCALAPPDATA%\\Packages\\Microsoft.WindowsTerminal_8wekyb3d8bbwe\\LocalState\\settings.json "
292 "( type %LOCALAPPDATA%\\Packages\\Microsoft.WindowsTerminal_8wekyb3d8bbwe\\LocalState\\settings.json ) "
293 "else if exist %LOCALAPPDATA%\\Packages\\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\\LocalState\\settings.json "
294 "( type %LOCALAPPDATA%\\Packages\\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\\LocalState\\settings.json ) "
295 "else if exist \"%LOCALAPPDATA%\\Microsoft\\Windows Terminal\\settings.json\" "
296 "( type %LOCALAPPDATA%\\Microsoft\\Windows Terminal\\settings.json ) "
297 "else ( call )",
298 nullptr });
299 }
300
301 if (error) {
302 ffStrbufAppendS(&terminalFont->error, error);
303 return;
304 }
305 ffStrbufTrimRight(&json, '\n');
306 if (json.length == 0) {
307 ffStrbufAppendS(&terminalFont->error, "Cannot find file \"settings.json\"");
308 return;
309 }
310
311 FF_STRBUF_AUTO_DESTROY wtProfileId = ffStrbufCreateS(getenv("WT_PROFILE_ID"));
312 ffStrbufTrim(&wtProfileId, '\'');
313
314 [[gnu::cleanup(wrapWTFontFree)]] FFTerminalFontWT result = ffTerminalFontWTCreate();
315
316 error = detectFromWTSettings(&json, &wtProfileId, &result);
317 if (error) {
318 ffStrbufAppendS(&terminalFont->error, error);
319 return;
320 }
321
322 // JSON fragments are only read when settings.json doesn't fully specify the font
323 if (wtProfileId.length > 0 && (result.name.length == 0 || result.size < 0)) {
324 detectFromWTFragments(&wtProfileId, &result);
325 }
326
327 if (result.name.length == 0) {
328 ffStrbufAppendS(&result.name, "Cascadia Mono");
329 }
330 if (result.size < 0) {
331 result.size = 12;
332 }
333
334 char sizeStr[16];
335 snprintf(sizeStr, ARRAY_SIZE(sizeStr), "%g", result.size);
336 ffFontInitValues(&terminalFont->font, result.name.chars, sizeStr);
337}
338
339static void detectMintty(FFTerminalFontResult* terminalFont) {
340 FF_STRBUF_AUTO_DESTROY fontName = ffStrbufCreate();
341 FF_STRBUF_AUTO_DESTROY fontSize = ffStrbufCreate();
342
343 if (!ffParsePropFileConfigValues("mintty/config", 2, (FFpropquery[]) { { "Font=", &fontName }, { "FontHeight=", &fontSize } })) {
344 ffParsePropFileConfigValues(".minttyrc", 2, (FFpropquery[]) { { "Font=", &fontName }, { "FontHeight=", &fontSize } });
345 }
346 if (fontName.length == 0) {
347 ffStrbufAppendS(&fontName, "Lucida Console");
348 }
349 if (fontSize.length == 0) {
350 ffStrbufAppendC(&fontSize, '9');
351 }
352
353 ffFontInitValues(&terminalFont->font, fontName.chars, fontSize.chars);
354}
355
356static void detectConhost(FFTerminalFontResult* terminalFont) {
357 CONSOLE_FONT_INFOEX cfi = { .cbSize = sizeof(cfi) };
358 if (!GetCurrentConsoleFontEx(GetStdHandle(STD_OUTPUT_HANDLE), FALSE, &cfi)) {
359 ffStrbufAppendS(&terminalFont->error, "GetCurrentConsoleFontEx() failed");
360 return;
361 }
362
363 FF_STRBUF_AUTO_DESTROY fontName = ffStrbufCreateWS(cfi.FaceName);
364
365 char fontSize[16];
366 _ultoa((unsigned long) (cfi.dwFontSize.Y), fontSize, 10);
367
368 ffFontInitValues(&terminalFont->font, fontName.chars, fontSize);
369}
370
371static void detectConEmu(FFTerminalFontResult* terminalFont) {
372 // https://conemu.github.io/en/ConEmuXml.html#search-sequence
373 FF_STRBUF_AUTO_DESTROY path = ffStrbufCreate();
374 FF_STRBUF_AUTO_DESTROY fontName = ffStrbufCreate();
375 FF_STRBUF_AUTO_DESTROY fontSize = ffStrbufCreate();
376
377 const char* paths[] = { "ConEmuDir", "ConEmuBaseDir", "APPDATA" };
378 for (uint32_t i = 0; i < ARRAY_SIZE(paths); ++i) {
379 ffStrbufSetS(&path, getenv(paths[i]));
380 if (path.length > 0) {
381 ffStrbufAppendS(&path, "/ConEmu.xml");
382 if (ffParsePropFileValues(path.chars, 2, (FFpropquery[]) { { "<value name=\"FontName\" type=\"string\" data=\"", &fontName }, { "<value name=\"FontSize\" type=\"ulong\" data=\"", &fontSize } })) {
383 break;
384 }
385 }
386 }
387
388 if (fontName.length == 0 && fontSize.length == 0) {
389 ffStrbufAppendS(&terminalFont->error, "Failed to parse ConEmu.xml");
390 return;
391 }
392
393 if (fontName.length > 0) {
394 ffStrbufSubstrBeforeLastC(&fontName, '"');
395 } else {
396 ffStrbufAppendS(&fontName, "Consola");
397 }
398
399 if (fontSize.length > 0) {
400 ffStrbufSubstrBeforeLastC(&fontSize, '"');
401 } else {
402 ffStrbufAppendS(&fontSize, "14");
403 }
404
405 ffFontInitValues(&terminalFont->font, fontName.chars, fontSize.chars);
406}
407
408static void detectWarp(FFTerminalFontResult* terminalFont) {
409 FF_AUTO_CLOSE_FD HANDLE key = nullptr;
410 if (!ffRegOpenKeyForRead(HKEY_CURRENT_USER, L"Software\\Warp.dev\\Warp", &key, &terminalFont->error)) {
411 return;
412 }
413
414 FF_STRBUF_AUTO_DESTROY fontName = ffStrbufCreate();
415 FF_STRBUF_AUTO_DESTROY fontSize = ffStrbufCreate();
416 if (ffRegReadValues(key, 2, (FFRegValueArg[]) { FF_ARG(fontName, L"FontName"), FF_ARG(fontSize, L"FontSize") }, &terminalFont->error)) {
417 ffStrbufTrim(&fontName, '"');
418 ffStrbufAppendS(&fontSize, "px");
419 } else {
420 ffStrbufSetS(&fontName, "Hack");
421 ffStrbufSetS(&fontSize, "13.0px");
422 }
423
424 ffFontInitValues(&terminalFont->font, fontName.chars, fontSize.chars);
425
426 FFstrbuf* fontWeight = FF_LIST_ADD(FFstrbuf, terminalFont->font.styles);
427 ffStrbufInit(fontWeight);
428 if (ffRegReadStrbuf(key, L"FontWeight", fontWeight, nullptr)) {
429 ffStrbufTrim(fontWeight, '"');
430 } else {
431 ffStrbufSetStatic(fontWeight, "Normal");
432 }
433}
434
435bool ffDetectTerminalFontPlatform(const FFTerminalResult* terminal, FFTerminalFontResult* terminalFont) {
436 if (ffStrbufIgnCaseEqualS(&terminal->processName, "Windows Terminal") ||
437 ffStrbufIgnCaseEqualS(&terminal->processName, "WindowsTerminal.exe")) {
438 detectFromWindowsTerminal(&terminal->exe, terminalFont);
439 } else if (ffStrbufIgnCaseEqualS(&terminal->processName, "mintty")) {
440 detectMintty(terminalFont);
441 } else if (ffStrbufIgnCaseEqualS(&terminal->processName, "conhost.exe")) {
442 detectConhost(terminalFont);
443 } else if (ffStrbufStartsWithIgnCaseS(&terminal->processName, "ConEmu")) {
444 detectConEmu(terminalFont);
445 } else if (ffStrbufStartsWithIgnCaseS(&terminal->processName, "warp")) {
446 detectWarp(terminalFont);
447 } else {
448 return false;
449 }
450 return true;
451}