Packages (Windows): caches winget packages count; enables winget by default

This commit is contained in:
李通洲
2026-09-20 13:05:09 +08:00
parent 67da836a27
commit f96a290f3b
10 changed files with 150 additions and 47 deletions
+1
View File
@@ -15,3 +15,4 @@ fastfetch.kdev4
*.log
/*.zh.md
/.work*
/doc/*.md
+3 -1
View File
@@ -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)
+1 -5
View File
@@ -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)
+1 -1
View File
@@ -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)",
+17 -13
View File
@@ -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,
+16 -8
View File
@@ -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;
}
+4 -1
View File
@@ -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);
+28 -17
View File
@@ -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) {
+1
View File
@@ -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__)
+78 -1
View File
@@ -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\<source>\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) {