master ohmu/fastfetch-openbsd / src / detection / packages / packages_windows.c
ohmu  ·  2026-10-15
  1#include "packages.h"
  2#include "common/processing.h"
  3#include "common/strutil.h"
  4#include "common/path.h"
  5#include "common/windows/folders.h"
  6#include "common/windows/unicode.h"
  7#include "common/windows/registry.h"
  8#include "common/mallocHelper.h"
  9#include "common/io.h"
 10
 11#include <stdalign.h>
 12#include <windows.h>
 13#include "common/windows/nt.h"
 14#include <ntstatus.h>
 15
 16static uint32_t getNumElements(const char* searchPath, DWORD type, const wchar_t* ignore) {
 17    FF_AUTO_CLOSE_FD HANDLE dfd = CreateFileA(searchPath, FILE_LIST_DIRECTORY | SYNCHRONIZE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr);
 18    if (dfd == INVALID_HANDLE_VALUE) {
 19        return 0;
 20    }
 21
 22    bool flag = ignore == nullptr;
 23    uint32_t counter = 0;
 24    alignas(8) uint8_t buffer[64 * 1024];
 25    BOOLEAN firstScan = TRUE;
 26
 27    size_t ignoreLen = ignore ? wcslen(ignore) : 0;
 28
 29    while (true) {
 30        IO_STATUS_BLOCK ioStatus = {};
 31        NTSTATUS status = NtQueryDirectoryFile(
 32            dfd,
 33            nullptr,
 34            nullptr,
 35            nullptr,
 36            &ioStatus,
 37            buffer,
 38            ARRAY_SIZE(buffer),
 39            FileDirectoryInformation,
 40            FALSE,
 41            nullptr,
 42            firstScan);
 43        firstScan = FALSE;
 44
 45        if (!NT_SUCCESS(status) && status != STATUS_BUFFER_OVERFLOW) {
 46            break;
 47        }
 48
 49        for (FILE_DIRECTORY_INFORMATION* entry = (FILE_DIRECTORY_INFORMATION*) buffer;
 50            ;
 51            entry = (FILE_DIRECTORY_INFORMATION*) ((uint8_t*) entry + entry->NextEntryOffset)) {
 52            if (!(entry->FileAttributes & type)) {
 53                continue;
 54            }
 55
 56            if (!flag &&
 57                ignoreLen == entry->FileNameLength / sizeof(*entry->FileName) &&
 58                _wcsnicmp(entry->FileName, ignore, ignoreLen) == 0) {
 59                flag = true;
 60                continue;
 61            }
 62
 63            counter++;
 64
 65            if (entry->NextEntryOffset == 0) {
 66                break;
 67            }
 68        }
 69
 70        if (status == STATUS_SUCCESS) {
 71            break; // No next page
 72        }
 73    }
 74
 75    if (type == FILE_ATTRIBUTE_DIRECTORY && counter >= 2) {
 76        counter -= 2; // accounting for . and ..
 77    }
 78
 79    return counter;
 80}
 81
 82static inline void wrapYyjsonFree(yyjson_doc** doc) {
 83    assert(doc);
 84    if (*doc) {
 85        yyjson_doc_free(*doc);
 86    }
 87}
 88
 89static void detectScoop(FFPackagesResult* result) {
 90    FF_STRBUF_AUTO_DESTROY scoopPath = ffStrbufCreateA(MAX_PATH + 3);
 91    ffStrbufAppend(&scoopPath, &instance.state.platform.homeDir);
 92    ffStrbufAppendS(&scoopPath, ".config/scoop/config.json");
 93
 94    yyjson_val* root = nullptr;
 95
 96    [[gnu::cleanup(wrapYyjsonFree)]] yyjson_doc* doc = yyjson_read_file(scoopPath.chars, 0, nullptr, nullptr);
 97    if (doc) {
 98        root = yyjson_doc_get_root(doc);
 99        if (!yyjson_is_obj(root)) {
100            root = nullptr;
101        }
102    }
103
104    {
105        ffStrbufClear(&scoopPath);
106        if (root) {
107            ffStrbufSetJsonVal(&scoopPath, yyjson_obj_get(root, "root_path"));
108        }
109        if (scoopPath.length == 0) {
110            ffStrbufSet(&scoopPath, &instance.state.platform.homeDir);
111            ffStrbufAppendS(&scoopPath, "/scoop");
112        }
113        ffStrbufAppendS(&scoopPath, "/apps/");
114        result->scoopUser = getNumElements(scoopPath.chars, FILE_ATTRIBUTE_DIRECTORY, L"scoop");
115    }
116
117    {
118        ffStrbufClear(&scoopPath);
119        if (root) {
120            ffStrbufSetJsonVal(&scoopPath, yyjson_obj_get(root, "global_path"));
121        }
122        if (scoopPath.length == 0) {
123            // Leaving `scoopPath` empty when the lookup fails makes `getNumElements` report 0, which
124            // is what the shell API failing used to do as well.
125            ffGetKnownFolderPath(FF_KNOWN_FOLDER_PROGRAM_DATA, &scoopPath);
126            ffStrbufAppendS(&scoopPath, "/scoop");
127        }
128        ffStrbufAppendS(&scoopPath, "/apps/");
129        result->scoopGlobal = getNumElements(scoopPath.chars, FILE_ATTRIBUTE_DIRECTORY, L"scoop");
130    }
131}
132
133static void detectChoco([[maybe_unused]] FFPackagesResult* result) {
134    const char* chocoInstall = getenv("ChocolateyInstall");
135    if (!chocoInstall || chocoInstall[0] == '\0') {
136        return;
137    }
138
139    char chocoPath[MAX_PATH + 3];
140    char* pend = ffStrCopy(chocoPath, chocoInstall, ARRAY_SIZE(chocoPath));
141    ffStrCopy(pend, "/lib/", ARRAY_SIZE(chocoPath) - (size_t) (pend - chocoPath));
142    result->choco = getNumElements(chocoPath, FILE_ATTRIBUTE_DIRECTORY, L"choco");
143}
144
145static void detectPacman(FFPackagesResult* result) {
146    const char* msystemPrefix = getenv("MSYSTEM_PREFIX");
147    if (!msystemPrefix) {
148        return;
149    }
150
151    // MSYS2
152    char pacmanPath[MAX_PATH + 3];
153    char* pend = ffStrCopy(pacmanPath, msystemPrefix, ARRAY_SIZE(pacmanPath));
154    ffStrCopy(pend, "/../var/lib/pacman/local/", ARRAY_SIZE(pacmanPath) - (size_t) (pend - pacmanPath));
155    result->pacman = getNumElements(pacmanPath, FILE_ATTRIBUTE_DIRECTORY, nullptr);
156}
157
158// The newest last-write time among the registry keys that hold the set of installed programs.
159//
160// winget is the only package manager on Windows whose count cannot be read from a local index:
161// `winget list` has to spawn a process (~1.5 s), so its result is cached. The obvious cache key --
162// the modification time of winget's own database -- does not work. `installed.db` is a
163// `PackageTrackingCatalog`: it only records the installs winget performed itself. Measured on a
164// machine with 101 listed packages: it holds 46 ids, and 33 of the listed ones are absent from it.
165// Uninstalling a package through Settings > Apps leaves the file completely untouched (same mtime,
166// same size, same row count) while `winget list` loses a row.
167//
168// The sources `winget list` correlates against are the ARP registry and the MSIX package registry.
169// Adding or removing a program bumps the last-write time of the parent key it lives under, so the
170// newest of these four changes exactly when the count may have changed.
171// All four must be read: a program may register in either ARP view, or be an MSIX package.
172//
173// The keys are read through registry.h, like every other Windows detection module does. There the
174// 32-bit view has to be addressed as a path: NtOpenKey ignores KEY_WOW64_32KEY -- it hands back the
175// 64-bit key with the flag set just as with it cleared (measured), the redirect is done by the Win32
176// Reg* wrappers. What the redirector resolves to is the physical `SOFTWARE\WOW6432Node\...`, so
177// opening that path is equivalent to asking for KEY_WOW64_32KEY.
178static uint64_t getInstalledProgramsFingerprint(void) {
179    static const struct {
180        HKEY root;
181        const wchar_t* path;
182    } locations[] = {
183        { HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall" },
184#ifdef _WIN64
185        // A 64-bit program may still register in the 32-bit view -- the installer decides. Geekbench 6
186        // does, and reading only the 64-bit view misses its uninstall entirely.
187        //
188        // A 32-bit fastfetch is supported on 32-bit Windows only (a 32-bit process on 64-bit Windows
189        // would need KEY_WOW64_64KEY, which this API cannot express): there the key above is the one
190        // and only ARP view, and this one does not exist.
191        { HKEY_LOCAL_MACHINE, L"SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall" },
192#endif
193        { HKEY_CURRENT_USER, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall" },
194        { HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\AppModel\\StateRepository\\Cache\\Package\\Data" },
195    };
196
197    uint64_t newest = 0;
198    for (size_t i = 0; i < ARRAY_SIZE(locations); ++i) {
199        FF_AUTO_CLOSE_FD HANDLE hKey = nullptr;
200        if (!ffRegOpenKeyForRead(locations[i].root, locations[i].path, &hKey, nullptr)) {
201            continue;
202        }
203
204        KEY_CACHED_INFORMATION info = {};
205        if (ffRegQueryKey(hKey, &info, nullptr) && (uint64_t) info.LastWriteTime.QuadPart > newest) {
206            newest = (uint64_t) info.LastWriteTime.QuadPart;
207        }
208    }
209
210    return newest;
211}
212
213static void detectWinget(FFPackagesResult* result) {
214    // Why not read winget's own database instead of shelling out?
215    // `%LOCALAPPDATA%\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\<source>\installed.db`
216    // is a `PackageTrackingCatalog`: it records the install / uninstall actions winget performed for that
217    // source, not a snapshot of what is currently installed. Uninstalling with anything but winget leaves
218    // the record behind forever, so counting it also counts packages that are long gone. Its schema is
219    // private and versioned (1.3, while the source index is 2.0), winget opens it ReadWrite while running,
220    // and reading it would drag in a SQLite dependency.
221    // The authoritative set of installed packages comes from the ARP registry and MSIX, which is exactly
222    // what `winget list` enumerates before correlating it against the read-only source index.
223
224    // The fingerprint is read once, before the process runs, and reused as the cache key on a miss.
225    // `winget list` writes to none of those keys, so a stored key stays valid until a program changes.
226    const uint64_t cacheKey = getInstalledProgramsFingerprint();
227
228    FF_STRBUF_AUTO_DESTROY cacheDir = ffStrbufCreate();
229    FF_STRBUF_AUTO_DESTROY cacheContent = ffStrbufCreate();
230
231    uint32_t count;
232    if (ffPackagesReadCacheKey(&cacheDir, &cacheContent, cacheKey, "winget", &count)) {
233        result->winget = count;
234        return;
235    }
236
237    FF_STRBUF_AUTO_DESTROY buffer = ffStrbufCreate();
238    if (ffProcessAppendStdOut(&buffer, (char*[]) {
239                                           "winget.exe",
240                                           "list",
241                                           // Without `--source winget`, winget also lists every package installed by
242                                           // other means (ARP / MSIX), which are not winget packages at all.
243                                           // It also skips the msstore HTTP round-trips, which are the main reason
244                                           // why `winget list` is slow and its latency unpredictable.
245                                           "--source",
246                                           "winget",
247                                           "--disable-interactivity",
248                                           // Accepting is a no-op once it has been done. Without it, `winget list`
249                                           // exits with code 1 on a machine that has never accepted the source
250                                           // agreements, and this module would report no packages at all.
251                                           "--accept-source-agreements",
252                                           nullptr,
253                                       })) {
254        // A failed detection is not cached: only ffPackagesWriteCache() writes, and it is not reached.
255        return;
256    }
257
258    uint32_t index = ffStrbufFirstIndexS(&buffer, "--\r\n"); // Ignore garbage and table headers
259    if (index == buffer.length) {
260        return;
261    }
262
263    count = 0;
264    for (
265        index += strlen("--\r\n");
266        (index = ffStrbufNextIndexC(&buffer, index, '\n')) < buffer.length;
267        ++index) {
268        ++count;
269    }
270
271    if (buffer.chars[buffer.length - 1] != '\n') { // count last line
272        ++count;
273    }
274
275    result->winget = count;
276
277    ffPackagesWriteCache(&cacheDir, &cacheContent, count);
278}
279
280void ffDetectPackagesImpl(FFPackagesResult* result, FFPackagesOptions* options) {
281    if (FF_PACKAGES_IS_ENABLED(options, SCOOP)) {
282        detectScoop(result);
283    }
284    if (FF_PACKAGES_IS_ENABLED(options, CHOCO)) {
285        detectChoco(result);
286    }
287    if (FF_PACKAGES_IS_ENABLED(options, PACMAN)) {
288        detectPacman(result);
289    }
290    if (FF_PACKAGES_IS_ENABLED(options, WINGET)) {
291        detectWinget(result);
292    }
293}