master ohmu/fastfetch-openbsd / src / detection / terminalfont / terminalfont_windows.c
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}