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}