From f96a290f3bd5f5a3b4cff7a018cf668f721c2d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=80=9A=E6=B4=B2?= Date: Sun, 20 Sep 2026 13:05:09 +0800 Subject: [PATCH] Packages (Windows): caches winget packages count; enables winget by default --- .gitignore | 1 + CHANGELOG.md | 4 +- CMakeLists.txt | 6 +- doc/json_schema.json | 2 +- src/common/windows/nt.h | 30 +++++---- src/common/windows/registry.c | 24 ++++--- src/common/windows/registry.h | 5 +- src/detection/packages/packages.c | 45 ++++++++----- src/detection/packages/packages.h | 1 + src/detection/packages/packages_windows.c | 79 ++++++++++++++++++++++- 10 files changed, 150 insertions(+), 47 deletions(-) diff --git a/.gitignore b/.gitignore index 553bdfe75..dd4b7d352 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ fastfetch.kdev4 *.log /*.zh.md /.work* +/doc/*.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa566349..7f4436ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,9 @@ Features: * The version is now read from the `COSMIC_VERSION` environment variable when it is set. * Improved accuracy and performance of process name detection in the Top module. (Top, macOS) * Improved Packages detection on Windows (Packages, Windows) - * `winget list` is now invoked with `--source winget`, so only packages installed by winget itself are counted, and the slow msstore HTTP round trips are skipped. + * `winget` is now detected by default, instead of requiring a build-time opt-in, and the source agreements are accepted non-interactively on a machine that has never accepted them. Opt out by setting `packages.disabled` to `["winget"]`, or by configuring with `cmake -DPACKAGES_DISABLE_WINGET=ON`. + * The count reports the packages the `winget` source knows about, which is not limited to the packages `winget` itself installed. Microsoft Store apps are no longer counted. + * `winget list` takes about 1.5 seconds, so its result is cached. The cache is refreshed when a program is installed or removed, not on a timer, so only the first run after such a change is slow. * Improved Wallpaper detection on macOS Sonoma and later (#2559, Wallpaper, macOS) * The image path is now also extracted from the `Configuration` field of the wallpaper plist, and the `NSWorkspace` fallback is used only as a last resort. * Removed the `kvm` dependency on OpenBSD by using `sysctl` directly. (General, OpenBSD) diff --git a/CMakeLists.txt b/CMakeLists.txt index 75437aec9..814ef080e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -161,11 +161,7 @@ string(REGEX MATCHALL "FF_PACKAGES_FLAG_[A-Z]+_BIT" PACKAGE_MANAGERS "${PACKAGE_ string(REGEX REPLACE "FF_PACKAGES_FLAG_([A-Z]+)_BIT" "\\1" PACKAGE_MANAGERS "${PACKAGE_MANAGERS}") list(SORT PACKAGE_MANAGERS) foreach(package_manager ${PACKAGE_MANAGERS}) - if(package_manager STREQUAL "WINGET") - option(PACKAGES_DISABLE_${package_manager} "Disable ${package_manager} package manager detection by default" ON) - else() - option(PACKAGES_DISABLE_${package_manager} "Disable ${package_manager} package manager detection by default" OFF) - endif() + option(PACKAGES_DISABLE_${package_manager} "Disable ${package_manager} package manager detection by default" OFF) endforeach() option(PACKAGES_REMOVE_DISABLED "Remove disabled package managers from the build entirely, instead of just skipping them at runtime" OFF) diff --git a/doc/json_schema.json b/doc/json_schema.json index a4ec51f8f..829998d4b 100644 --- a/doc/json_schema.json +++ b/doc/json_schema.json @@ -3812,7 +3812,7 @@ "type": "null" } ], - "default": ["winget"] + "default": [] }, "combined": { "description": "Whether to combine related package managers into single counts (e.g., nix-system + nix-user = nix)", diff --git a/src/common/windows/nt.h b/src/common/windows/nt.h index dd2caa06d..26b214dc4 100644 --- a/src/common/windows/nt.h +++ b/src/common/windows/nt.h @@ -1307,19 +1307,23 @@ typedef struct _KEY_BASIC_INFORMATION { _Field_size_bytes_(NameLength) WCHAR Name[]; // The name of the registry key. This string is not null-terminated. } KEY_BASIC_INFORMATION, *PKEY_BASIC_INFORMATION; -typedef struct _KEY_FULL_INFORMATION { - LARGE_INTEGER LastWriteTime; - ULONG TitleIndex; - ULONG ClassOffset; - ULONG ClassLength; - ULONG SubKeys; - ULONG MaxNameLength; - ULONG MaxClassLength; - ULONG Values; - ULONG MaxValueNameLength; - ULONG MaxValueDataLength; - WCHAR Class[]; -} KEY_FULL_INFORMATION, *PKEY_FULL_INFORMATION; +typedef struct _KEY_CACHED_INFORMATION { + LARGE_INTEGER LastWriteTime; // Number of 100-nanosecond intervals since this key or any of its values changed. + ULONG TitleIndex; // Reserved // A legacy field originally intended for use with localization such as an index of a resource table. + ULONG SubKeys; // The number of subkeys for a key. + ULONG MaxNameLen; // The maximum length, in bytes, of a subkey name. + ULONG Values; // The number of value entries. + ULONG MaxValueNameLen; // The maximum length, in bytes, of a value entry name. + ULONG MaxValueDataLen; // The maximum length, in bytes, of a value entry data field. + ULONG NameLength; // Size, in bytes, of the key's own (leaf) name, without the terminator. The name itself is not stored. +} KEY_CACHED_INFORMATION, *PKEY_CACHED_INFORMATION; +// 36 bytes of members, but LARGE_INTEGER makes the struct 8-byte aligned, and that is exactly what +// the kernel demands: a 36-byte buffer is rejected with STATUS_BUFFER_TOO_SMALL (measured). +// The kernel writes all 40 bytes -- the 4 padding bytes included, zeroed -- and reports +// ResultLength = 40 whatever size you pass, so a larger buffer buys nothing (measured 40..4096). +// This is the only information class that never appends a string: NameLength is the length of the +// key's own (leaf) name in bytes without the terminator, and the name itself is not returned. +static_assert(sizeof(KEY_CACHED_INFORMATION) == 40, "KEY_CACHED_INFORMATION should be 40 bytes"); NTSYSAPI NTSTATUS NTAPI NtQueryKey( _In_ HANDLE KeyHandle, diff --git a/src/common/windows/registry.c b/src/common/windows/registry.c index 8bd2e09fd..a3a8d3255 100644 --- a/src/common/windows/registry.c +++ b/src/common/windows/registry.c @@ -355,6 +355,10 @@ bool ffRegGetSubKey(HANDLE hKey, uint32_t index, FFstrbuf* result, FFstrbuf* err ULONG bufSize = (ULONG) sizeof(buffer); KEY_BASIC_INFORMATION* keyInfo = (KEY_BASIC_INFORMATION*) buffer; + // Every failure mode fails NT_SUCCESS, and the read below depends on that: on a too-small buffer + // the kernel returns STATUS_BUFFER_OVERFLOW, writes the *full* NameLength but copies only part of + // the name (measured), so treating that status as success would read past what was written. + // The longest name the CM accepts, 255 characters, still fits: 255 * 2 + 16 <= sizeof(buffer). if (!NT_SUCCESS(NtEnumerateKey(hKey, index, KeyBasicInformation, keyInfo, bufSize, &bufSize))) { FF_DEBUG("NtEnumerateKey(hKey=%p, index=%u) failed", hKey, (unsigned) index); if (error) { @@ -367,22 +371,26 @@ bool ffRegGetSubKey(HANDLE hKey, uint32_t index, FFstrbuf* result, FFstrbuf* err return true; } -bool ffRegGetNSubKeys(HANDLE hKey, uint32_t* result, FFstrbuf* error) { +// Reads the counters the key object already caches. KeyFullInformation would return the same numbers +// but also marshals the key's class string out; measured per query on the ARP and MSIX keys: +// 837 ns vs 895 ns (interleaved, 200k queries each). The open dominates the total -- 22.10 us vs +// 22.40 us for open + query -- so this is a small win, not a big one. +// +// The kernel writes exactly sizeof(KEY_CACHED_INFORMATION) bytes and rejects a smaller buffer with +// STATUS_BUFFER_TOO_SMALL without writing anything, so pass a whole KEY_CACHED_INFORMATION. +bool ffRegQueryKey(HANDLE hKey, KEY_CACHED_INFORMATION* result, FFstrbuf* error) { assert(hKey); assert(result); - alignas(KEY_FULL_INFORMATION) uint8_t buffer[sizeof(KEY_FULL_INFORMATION) + MAX_PATH * sizeof(wchar_t)]; - ULONG bufSize = sizeof(buffer); - KEY_FULL_INFORMATION* keyInfo = (KEY_FULL_INFORMATION*) buffer; + ULONG bufSize = (ULONG) sizeof(*result); - if (!NT_SUCCESS(NtQueryKey(hKey, KeyFullInformation, keyInfo, bufSize, &bufSize))) { - FF_DEBUG("NtQueryKey(hKey=%p, KeyFullInformation) failed", hKey); + if (!NT_SUCCESS(NtQueryKey(hKey, KeyCachedInformation, result, bufSize, &bufSize))) { + FF_DEBUG("NtQueryKey(hKey=%p, KeyCachedInformation) failed", hKey); if (error) { - ffStrbufAppendF(error, "NtQueryKey(hKey=%p, KeyFullInformation, keyInfo) failed", hKey); + ffStrbufAppendF(error, "NtQueryKey(hKey=%p, KeyCachedInformation, keyInfo) failed", hKey); } return false; } - *result = (uint32_t) keyInfo->SubKeys; return true; } diff --git a/src/common/windows/registry.h b/src/common/windows/registry.h index 974537241..5162354f1 100644 --- a/src/common/windows/registry.h +++ b/src/common/windows/registry.h @@ -3,6 +3,7 @@ #include "fastfetch.h" #include "common/argType.h" #include "common/io.h" +#include "common/windows/nt.h" #ifndef HKEY_CURRENT_USER #define HKEY_CLASSES_ROOT ((HKEY) (ULONG_PTR) ((LONG) 0x80000000)) @@ -26,7 +27,9 @@ bool ffRegOpenSubkeyForRead(HANDLE hKey, const wchar_t* subKeyW, HANDLE* result, bool ffRegReadValue(HANDLE hKey, const FFRegValueArg* arg, FFstrbuf* error); bool ffRegReadValues(HANDLE hKey, uint32_t argc, const FFRegValueArg argv[], FFstrbuf* error); bool ffRegGetSubKey(HANDLE hKey, uint32_t index, FFstrbuf* result, FFstrbuf* error); -bool ffRegGetNSubKeys(HANDLE hKey, uint32_t* result, FFstrbuf* error); +// Fills `result` with the counters the key object caches, including LastWriteTime: the number of +// 100-nanosecond intervals since this key or any of its values was last changed (FILETIME epoch). +bool ffRegQueryKey(HANDLE hKey, KEY_CACHED_INFORMATION* result, FFstrbuf* error); static inline bool ffRegOpenKeyForRead(HKEY hRootKey, const wchar_t* subKeyW, HANDLE* result, FFstrbuf* error) { return ffRegOpenSubkeyForRead(ffRegGetRootKeyHandle(hRootKey), subKeyW, result, error); diff --git a/src/detection/packages/packages.c b/src/detection/packages/packages.c index a508342d8..312c8aad5 100644 --- a/src/detection/packages/packages.c +++ b/src/detection/packages/packages.c @@ -16,6 +16,33 @@ const char* ffDetectPackages(FFPackagesResult* result, FFPackagesOptions* option return nullptr; } +bool ffPackagesReadCacheKey(FFstrbuf* cacheDir, FFstrbuf* cacheContent, uint64_t cacheKey, const char* packageId, uint32_t* result) { + if (__builtin_expect(cacheKey == 0, false)) { + // The caller could not compute a cache key (its data source is unreadable). Nothing may be + // cached under an unknown key, and reporting 0 would be a lie. Leaving `cacheContent` empty + // makes the matching ffPackagesWriteCache() call a no-op. + return false; + } + + ffStrbufSet(cacheDir, &instance.state.platform.cacheDir); + ffStrbufEnsureEndsWithC(cacheDir, '/'); + ffStrbufAppendF(cacheDir, "fastfetch/packages/%s.txt", packageId); + + if (ffReadFileBuffer(cacheDir->chars, cacheContent)) { + uint64_t key_cached; + uint32_t num_cached; + if (sscanf(cacheContent->chars, "%" SCNu64 " %" SCNu32, &key_cached, &num_cached) == 2 && + key_cached == cacheKey && num_cached > 0) { + *result = num_cached; + return true; + } + } + + ffStrbufSetF(cacheContent, "%" PRIu64 " ", cacheKey); + + return false; +} + bool ffPackagesReadCache(FFstrbuf* cacheDir, FFstrbuf* cacheContent, const char* filePath, const char* packageId, uint32_t* result) { const uint64_t mtime_current = ffPathGetMtime(filePath); if (__builtin_expect(mtime_current == 0, false)) { @@ -31,23 +58,7 @@ bool ffPackagesReadCache(FFstrbuf* cacheDir, FFstrbuf* cacheContent, const char* return true; } - ffStrbufSet(cacheDir, &instance.state.platform.cacheDir); - ffStrbufEnsureEndsWithC(cacheDir, '/'); - ffStrbufAppendF(cacheDir, "fastfetch/packages/%s.txt", packageId); - - if (ffReadFileBuffer(cacheDir->chars, cacheContent)) { - uint64_t mtime_cached; - uint32_t num_cached; - if (sscanf(cacheContent->chars, "%" SCNu64 " %" SCNu32, &mtime_cached, &num_cached) == 2 && - mtime_cached == mtime_current && num_cached > 0) { - *result = num_cached; - return true; - } - } - - ffStrbufSetF(cacheContent, "%" PRIu64 " ", mtime_current); - - return false; + return ffPackagesReadCacheKey(cacheDir, cacheContent, mtime_current, packageId, result); } bool ffPackagesWriteCache(FFstrbuf* cacheDir, FFstrbuf* cacheContent, uint32_t num_elements) { diff --git a/src/detection/packages/packages.h b/src/detection/packages/packages.h index b7d9c90eb..08623f872 100644 --- a/src/detection/packages/packages.h +++ b/src/detection/packages/packages.h @@ -65,6 +65,7 @@ typedef struct FFPackagesResult { const char* ffDetectPackages(FFPackagesResult* result, FFPackagesOptions* options); bool ffPackagesReadCache(FFstrbuf* cacheDir, FFstrbuf* cacheContent, const char* filePath, const char* packageId, uint32_t* result); +bool ffPackagesReadCacheKey(FFstrbuf* cacheDir, FFstrbuf* cacheContent, uint64_t cacheKey, const char* packageId, uint32_t* result); bool ffPackagesWriteCache(FFstrbuf* cacheDir, FFstrbuf* cacheContent, uint32_t num_elements); #if defined(__linux__) || defined(__APPLE__) || defined(__GNU__) diff --git a/src/detection/packages/packages_windows.c b/src/detection/packages/packages_windows.c index 52100f8fe..3325c15a2 100644 --- a/src/detection/packages/packages_windows.c +++ b/src/detection/packages/packages_windows.c @@ -3,6 +3,7 @@ #include "common/strutil.h" #include "common/path.h" #include "common/windows/unicode.h" +#include "common/windows/registry.h" #include "common/mallocHelper.h" #include "common/io.h" @@ -156,6 +157,61 @@ static void detectPacman(FFPackagesResult* result) { result->pacman = getNumElements(pacmanPath, FILE_ATTRIBUTE_DIRECTORY, nullptr); } +// The newest last-write time among the registry keys that hold the set of installed programs. +// +// winget is the only package manager on Windows whose count cannot be read from a local index: +// `winget list` has to spawn a process (~1.5 s), so its result is cached. The obvious cache key -- +// the modification time of winget's own database -- does not work. `installed.db` is a +// `PackageTrackingCatalog`: it only records the installs winget performed itself. Measured on a +// machine with 101 listed packages: it holds 46 ids, and 33 of the listed ones are absent from it. +// Uninstalling a package through Settings > Apps leaves the file completely untouched (same mtime, +// same size, same row count) while `winget list` loses a row. +// +// The sources `winget list` correlates against are the ARP registry and the MSIX package registry. +// Adding or removing a program bumps the last-write time of the parent key it lives under, so the +// newest of these four changes exactly when the count may have changed. +// All four must be read: a program may register in either ARP view, or be an MSIX package. +// +// The keys are read through registry.h, like every other Windows detection module does. There the +// 32-bit view has to be addressed as a path: NtOpenKey ignores KEY_WOW64_32KEY -- it hands back the +// 64-bit key with the flag set just as with it cleared (measured), the redirect is done by the Win32 +// Reg* wrappers. What the redirector resolves to is the physical `SOFTWARE\WOW6432Node\...`, so +// opening that path is equivalent to asking for KEY_WOW64_32KEY. +static uint64_t getInstalledProgramsFingerprint(void) { + static const struct { + HKEY root; + const wchar_t* path; + } locations[] = { + { HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall" }, +#ifdef _WIN64 + // A 64-bit program may still register in the 32-bit view -- the installer decides. Geekbench 6 + // does, and reading only the 64-bit view misses its uninstall entirely. + // + // A 32-bit fastfetch is supported on 32-bit Windows only (a 32-bit process on 64-bit Windows + // would need KEY_WOW64_64KEY, which this API cannot express): there the key above is the one + // and only ARP view, and this one does not exist. + { HKEY_LOCAL_MACHINE, L"SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall" }, +#endif + { HKEY_CURRENT_USER, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall" }, + { HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\AppModel\\StateRepository\\Cache\\Package\\Data" }, + }; + + uint64_t newest = 0; + for (size_t i = 0; i < ARRAY_SIZE(locations); ++i) { + FF_AUTO_CLOSE_FD HANDLE hKey = nullptr; + if (!ffRegOpenKeyForRead(locations[i].root, locations[i].path, &hKey, nullptr)) { + continue; + } + + KEY_CACHED_INFORMATION info = {}; + if (ffRegQueryKey(hKey, &info, nullptr) && (uint64_t) info.LastWriteTime.QuadPart > newest) { + newest = (uint64_t) info.LastWriteTime.QuadPart; + } + } + + return newest; +} + static void detectWinget(FFPackagesResult* result) { // Why not read winget's own database instead of shelling out? // `%LOCALAPPDATA%\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\\installed.db` @@ -166,6 +222,20 @@ static void detectWinget(FFPackagesResult* result) { // and reading it would drag in a SQLite dependency. // The authoritative set of installed packages comes from the ARP registry and MSIX, which is exactly // what `winget list` enumerates before correlating it against the read-only source index. + + // The fingerprint is read once, before the process runs, and reused as the cache key on a miss. + // `winget list` writes to none of those keys, so a stored key stays valid until a program changes. + const uint64_t cacheKey = getInstalledProgramsFingerprint(); + + FF_STRBUF_AUTO_DESTROY cacheDir = ffStrbufCreate(); + FF_STRBUF_AUTO_DESTROY cacheContent = ffStrbufCreate(); + + uint32_t count; + if (ffPackagesReadCacheKey(&cacheDir, &cacheContent, cacheKey, "winget", &count)) { + result->winget = count; + return; + } + FF_STRBUF_AUTO_DESTROY buffer = ffStrbufCreate(); if (ffProcessAppendStdOut(&buffer, (char*[]) { "winget.exe", @@ -177,8 +247,13 @@ static void detectWinget(FFPackagesResult* result) { "--source", "winget", "--disable-interactivity", + // Accepting is a no-op once it has been done. Without it, `winget list` + // exits with code 1 on a machine that has never accepted the source + // agreements, and this module would report no packages at all. + "--accept-source-agreements", nullptr, })) { + // A failed detection is not cached: only ffPackagesWriteCache() writes, and it is not reached. return; } @@ -187,7 +262,7 @@ static void detectWinget(FFPackagesResult* result) { return; } - uint32_t count = 0; + count = 0; for ( index += strlen("--\r\n"); (index = ffStrbufNextIndexC(&buffer, index, '\n')) < buffer.length; @@ -200,6 +275,8 @@ static void detectWinget(FFPackagesResult* result) { } result->winget = count; + + ffPackagesWriteCache(&cacheDir, &cacheContent, count); } void ffDetectPackagesImpl(FFPackagesResult* result, FFPackagesOptions* options) {