Reusable scan strategy for metadata.json-driven subdirectory pack registries.
More...
#include <phosphor-fsloader/include/PhosphorFsLoader/MetadataPackScanStrategy.h>
Public Types | |
| using | Parser = std::function< std::optional< Payload >(const QString &subdirPath, const QJsonObject &root, bool isUser)> |
Parse one metadata.json into a payload. | |
| using | PerEntryWatchPaths = std::function< QStringList(const Payload &)> |
| Extract the per-payload paths the base must re-arm individual file watches on after every rescan. | |
| using | PerDirectoryWatchPaths = std::function< QStringList(const QString &searchPath)> |
| Optional: per-search-path watch additions beyond per-pack extraction. | |
| using | PerSubdirSkip = std::function< bool(const QString &subdirName)> |
| Optional: predicate skipping subdirectories whose bare name matches a sentinel. | |
| using | SignatureContrib = std::function< void(QCryptographicHash &, const Payload &)> |
| Optional: payload-specific bytes to fan into the per-rescan SHA-1 signature. | |
| using | OnCommit = std::function< void()> |
| Synchronous "the discovered set changed" hook. | |
Public Member Functions | |
| MetadataPackScanStrategy (Parser parser, OnCommit onCommit) | |
| Construct with the parser + commit hook (the two always-required policies). | |
| ~MetadataPackScanStrategy () override=default | |
| MetadataPackScanStrategy (const MetadataPackScanStrategy &)=delete | |
| MetadataPackScanStrategy & | operator= (const MetadataPackScanStrategy &)=delete |
| void | setPerEntryWatchPaths (PerEntryWatchPaths fn) |
| Set the per-payload watch-extractor. Default: empty list. | |
| void | setPerDirectoryWatchPaths (PerDirectoryWatchPaths fn) |
| Set the per-directory watch-extractor. Default: empty list. | |
| void | setPerSubdirSkip (PerSubdirSkip fn) |
| Set the per-subdir-name skip predicate. Default: never skip. | |
| void | setSignatureContrib (SignatureContrib fn) |
| Set the payload-specific signature contributor. Default: contributes nothing. | |
| void | setUserPath (const QString &path) |
Set the user-data search path used for isUser classification. | |
| void | setMaxEntries (int cap) |
| Per-rescan entry cap. | |
| void | setLoggingCategory (const QLoggingCategory &cat) |
| Override the logging category used for the strategy's own warnings (cap trip, oversized metadata.json, parse error, missing id). | |
| QStringList | performScan (const QStringList &directoriesInScanOrder) override |
Run a full rescan across directoriesInScanOrder. | |
| const QHash< QString, Payload > & | packsById () const |
| Live entries by id. | |
| QList< Payload > | packs () const |
| Live entries sorted by id for deterministic ordering. | |
| QStringList | packIds () const |
| Live entry ids in lexicographic order. | |
| Payload | pack (const QString &id) const |
| Lookup by id. | |
| bool | contains (const QString &id) const |
True if id is registered. | |
| int | size () const |
| Number of currently registered packs. | |
Public Member Functions inherited from PhosphorFsLoader::IScanStrategy | |
| virtual | ~IScanStrategy ()=default |
| IScanStrategy (const IScanStrategy &)=delete | |
| IScanStrategy & | operator= (const IScanStrategy &)=delete |
Static Public Attributes | |
| static constexpr int | kDefaultMaxEntries = 10'000 |
| Hard cap on successfully parsed entries discovered per rescan, summed across every registered search path. | |
Additional Inherited Members | |
Protected Member Functions inherited from PhosphorFsLoader::IScanStrategy | |
| IScanStrategy ()=default | |
Reusable scan strategy for metadata.json-driven subdirectory pack registries.
Owns the cross-cutting scaffolding both PhosphorShaders::ShaderRegistry and PhosphorAnimationShaders::AnimationShaderRegistry were duplicating verbatim:
directoriesInScanOrder (the canonical [lowest-priority, ..., highest-priority] shape from WatchedDirectorySet); first-registration-wins on id collision yields the XDG semantic user > sys-highest > ... > sys-lowest.metadata.json path, and stat-cap the file at DirectoryLoader::kMaxFileBytes before reading (untrusted same-user blob DoS guard).metadata.json into a caller-supplied schema-specific Payload via the Parser policy. Parser-returned std::nullopt skips the entry; an empty Payload::id skips it as well.SHA-1 signature over the sorted-by-id entry set. The strategy contributes id + isUser + metadata.json size+mtime per entry, AND mixes path|size|mtime per distinct watched file (the metadata.json itself, plus everything PerEntryWatchPaths and PerDirectoryWatchPaths returned) in lex-sorted order. Mixing the path string is load-bearing: a file moving from a sys-dir to the user-dir without any byte change must still register as a layering shift, since the isUser classification on the winning entry has flipped. The "watch list = signature scope" invariant means: any file the strategy is watching contributes to the signature, so any file change the watcher fires on translates to an OnCommit (modulo mtime/size shifting, which POSIX guarantees on content-changing writes). Without this auto-mix, an edit to a watched file the caller's SignatureContrib doesn't enumerate (a top-level shared *.glsl include, a per-pack helpers.glsl, etc.) would fire the rescan but leave the signature stable — OnCommit would be silenced and consumers would hold stale state. OnCommit fires only when the signature differs from the previous scan, giving the consumer change-only emit semantics on top of WatchedDirectorySet's unconditional rescanCompleted.
One known wrinkle: metadata.json files that fail validation (parse error, oversized, empty id, parser declined) are still added to the watch set so a fix-up edit re-fires the rescan. That means an in-place edit of a still-broken file shifts the watch-set fingerprint and trips OnCommit even though no visible state changed. Consumers gating their public signal on OnCommit will emit a spurious "changed" event in that case; availableShaders() / availableEffects() reflect the truth — the spurious wakeup is harmless beyond a redundant QML rebind. A subdir-walk that's bounded only by the filesystem (see kDefaultMaxEntries) makes pinning this stricter not worth the loop bookkeeping.
packs()) so QHash randomisation never surfaces in downstream UI / snapshot tests.isUser classification per entry, derived from a caller-supplied user-path and per-iterated-dir canonicalisation.Schema-specific concerns are delegated to caller-supplied policy callables (held by value as std::function members; see "Lifetime" below):
Parser — turns one (subdirPath, jsonRoot, isUser) into an optional Payload. The strategy DOES NOT prescribe which JSON fields the parser consumes; mutating the payload's id, applying directory-relative path resolution, and any inline validation (e.g. multipass-needs-buffer-shader) all happen here.PerEntryWatchPaths — extracts the per-payload watch paths (frag/vert/kwin/include shaders, additional metadata) the WatchedDirectorySet base must re-arm individual file watches on after every rescan. The strategy itself always watches the metadata.json; the callback adds payload-internal paths.PerDirectoryWatchPaths (optional) — additional paths to watch per registered search path, NOT per pack. Used by the shader-pack registry to watch top-level shared *.glsl includes.PerSubdirSkip (optional) — predicate over the bare subdirectory name. Used by the shader-pack registry to reserve none as a "no shader" sentinel.SignatureContrib (optional) — fans the payload-specific fingerprint bytes into the running SHA-1. The strategy itself already contributes id + isUser + the metadata.json's size+mtime per entry, so any change to a parser-consumed metadata field surfaces as a signature shift without the contributor having to enumerate every field. Callers add what's OUTSIDE metadata.json and still affects rendering (frag-shader mtime+size, vertex-shader path, source-dir absolute path, etc.).OnCommit — invoked synchronously inside performScan after the fresh map has replaced the prior one, AND ONLY WHEN the SHA-1 signature differs. The consumer wires its content-changed signal in here. NOT invoked when the scan is empty AND the previous scan was also empty (the no-content baseline doesn't fire on repeated empty scans).Both real consumers (ShaderInfo in phosphor-shaders, AnimationShaderEffect in phosphor-animation-shaders) parse into concrete struct types they already own. A virtual IMetadataPackPayload interface would force each registry to box its parse output (heap allocation per pack) and hide its public payload type behind a base — making packs() / pack(id) lookup callers downcast on every access. A class template lets each registry keep its concrete payload type and share the per-rescan map via direct strongly-typed accessors. The template is also header-only, which keeps phosphor-fsloader itself free of dependencies on either consumer's payload schema.
The policy callables themselves are stored as std::function (one level of type-erasure indirection per call). Inlining-via-callable- type-template-params would shave that, but at this workload (one stat syscall + JSON parse per entry per rescan) the indirection cost is unmeasurable — keeping the policy types out of the template signature is the better readability tradeoff. The price is one set of compiler instantiations per consumer payload — negligible given there are exactly two of them.
The strategy is constructed by the consumer registry, held by reference inside its WatchedDirectorySet, and destroyed when the registry is. Parser / watch-extractor callables are stored by value (typically std::function); they may capture state by pointer/reference if the captured state outlives the registry.
All setX methods on this class (setMaxEntries, setUserPath, setLoggingCategory, setPerEntryWatchPaths, etc.) take effect on the next rescan. Calling them from inside a Parser / SignatureContrib / OnCommit callback (i.e. while a performScan is on the stack) is undefined — the running scan reads its own snapshot of the config at the top, and a mid-scan setter call would either be ignored or interleave inconsistently. Configure the strategy once during ctor / immediately after, then leave it alone.
GUI-thread only. Inherits WatchedDirectorySet's threading constraint — performScan runs on the same thread the registry was constructed on.
| Payload | POD-ish struct exposing a public QString id member. Must be default-constructible and movable. Both real consumers (ShaderInfo, AnimationShaderEffect) satisfy this. Bespoke payloads without an id field can wrap their data in a thin POD that adds one. |
| using PhosphorFsLoader::MetadataPackScanStrategy< Payload >::OnCommit = std::function<void()> |
Synchronous "the discovered set changed" hook.
Invoked from inside performScan after the fresh map replaces the previous one. The consumer wires its public content-changed signal emission in here. The strategy guarantees the signature has actually changed before invoking; consumers don't double-diff.
| using PhosphorFsLoader::MetadataPackScanStrategy< Payload >::Parser = std::function<std::optional<Payload>(const QString& subdirPath, const QJsonObject& root, bool isUser)> |
Parse one metadata.json into a payload.
The strategy already validated (a) the file exists, (b) it is under DirectoryLoader::kMaxFileBytes, (c) parsing succeeded and the root is a JSON object, before invoking the parser. Returning std::nullopt skips the entry silently; the strategy will warn at the call site below where appropriate.
The parser is responsible for resolving directory-relative paths inside root against subdirPath, applying any inline validation, and stamping the payload's isUser flag from isUser if the schema exposes one. Returned payloads with an empty id are dropped by the caller (warning logged).
| using PhosphorFsLoader::MetadataPackScanStrategy< Payload >::PerDirectoryWatchPaths = std::function<QStringList(const QString& searchPath)> |
Optional: per-search-path watch additions beyond per-pack extraction.
Use for shared top-level files inside the search path (e.g. common.glsl in the shader-pack registry). Default: returns an empty list.
| using PhosphorFsLoader::MetadataPackScanStrategy< Payload >::PerEntryWatchPaths = std::function<QStringList(const Payload&)> |
Extract the per-payload paths the base must re-arm individual file watches on after every rescan.
The strategy always adds the metadata.json itself; this callback is for everything else (frag / vert / kwin shaders, additional metadata, etc.).
| using PhosphorFsLoader::MetadataPackScanStrategy< Payload >::PerSubdirSkip = std::function<bool(const QString& subdirName)> |
Optional: predicate skipping subdirectories whose bare name matches a sentinel.
Default: never skip. The shader-pack registry uses this to reserve none for "no shader".
| using PhosphorFsLoader::MetadataPackScanStrategy< Payload >::SignatureContrib = std::function<void(QCryptographicHash&, const Payload&)> |
Optional: payload-specific bytes to fan into the per-rescan SHA-1 signature.
The strategy already mixes in id, isUser, the metadata.json's size+mtime per entry, AND the size+mtime of every file PerEntryWatchPaths / PerDirectoryWatchPaths returned — so anything reachable via the watch list is already covered without enumeration. Use SignatureContrib only for non-file payload bytes that aren't represented in the watch set (e.g. a hash of an embedded resource). Default: contributes nothing.
|
inline |
Construct with the parser + commit hook (the two always-required policies).
Optional policies default to no-ops via the setter API below.
| parser | Schema-specific metadata.json → Payload parser. |
| onCommit | Called only when the per-rescan signature differs from the previous scan (empty parser results compared to a previous empty scan are not commits). |
|
overridedefault |
|
delete |
|
inline |
True if id is registered.
|
delete |
|
inline |
Lookup by id.
Returns a default-constructed Payload if the id is not registered (consistent with QHash::value).
|
inline |
Live entry ids in lexicographic order.
Cheaper than packs() when the consumer only needs the keys (no per-payload copy); the strategy already maintains the canonical sort, so consumers should call this rather than packsById().keys() + std::sort.
|
inline |
Live entries sorted by id for deterministic ordering.
QHash iteration order is randomised in Qt6; consumers exposing pack lists to UI / tests should use this rather than packsById().
|
inline |
Live entries by id.
|
overridevirtual |
Run a full rescan across directoriesInScanOrder.
See IScanStrategy::performScan for the canonical input shape. The strategy always rebuilds its full pack map from scratch — no incremental update. Stale entries (subdirs whose metadata.json vanished since the last scan) drop out by being absent from the rebuilt map; the next signature comparison reports the change and OnCommit fires.
metadata.json, plus everything PerEntryWatchPaths and PerDirectoryWatchPaths returned. Lex-sorted and deduplicated (the same canonical form already used internally for the signature pass) so callers can rely on stable ordering across rescans regardless of which search-path / subdir-traversal interleaving produced each entry. Does NOT include the registered search paths themselves — the base already watches those directly. Implements PhosphorFsLoader::IScanStrategy.
|
inline |
Override the logging category used for the strategy's own warnings (cap trip, oversized metadata.json, parse error, missing id).
Defaults to an internal phosphorfsloader.metadatapackscan category — the override exists so each consumer's log output keeps its per-registry filterability.
Stored as a borrowed pointer; cat must outlive the strategy. The standard caller pattern — setLoggingCategory(lcMyRegistry()) where lcMyRegistry is defined via Q_LOGGING_CATEGORY — gives a static-lifetime category and trivially satisfies this. Reference (not pointer) parameter mirrors MetadataPackRegistryBase's ctor and makes "always non-null" a compile-time guarantee.
|
inline |
Per-rescan entry cap.
Default: kDefaultMaxEntries.
Negative values are a programming error — the cap loop casts to size_t for the comparison, and a negative cast wraps to SIZE_MAX and silently disables the guard. Asserted in debug builds and clamped to zero in release so the wrap can't happen.
|
inline |
Set the per-directory watch-extractor. Default: empty list.
|
inline |
Set the per-payload watch-extractor. Default: empty list.
|
inline |
Set the per-subdir-name skip predicate. Default: never skip.
|
inline |
Set the payload-specific signature contributor. Default: contributes nothing.
|
inline |
Set the user-data search path used for isUser classification.
The strategy canonicalises this via QFileInfo::canonicalFilePath once per rescan and compares each iterated dir's canonical form against it. Empty (the default) yields false for every entry's isUser. The path does not need to exist at the time of the call — canonicalFilePath resolves once it materialises and subsequent rescans pick up the classification.
Setting this directly on the strategy does not trigger a rescan — the new value takes effect on the next scan, whenever that fires. Production consumers reach the strategy through MetadataPackRegistryBase::setUserPath, which orchestrates a synchronous rescanNow() after forwarding the value, so existing pack classifications refresh immediately. Direct strategy access (e.g. tests using static_cast<ScanStrategy*>(strategy())) bypasses that orchestration; pair the call with an explicit WatchedDirectorySet::rescanNow() if immediate reclassification is required.
|
inline |
Number of currently registered packs.
|
staticconstexpr |
Hard cap on successfully parsed entries discovered per rescan, summed across every registered search path.
Mirrors DirectoryLoader::kMaxEntries — every fsloader-backed registry caps at the same scale. Typical pack counts are single digits; the cap is purely a DoS guard against pathological user dirs with thousands of metadata.json-bearing subdirs.
Note: the cap does not bound the number of subdirs iterated. A search path with N subdirs that all lack a valid metadata.json will still loop through all N (each one stat'd, then skipped) — the cap protects against parsed-payload blowup, not directory- walk blowup. The bounded-iteration guarantee comes from WatchedDirectorySet's forbidden-root check (which prevents $HOME and friends from being registered) and from caller-side path discipline. If a future caller registers an untrusted-tree-of-subdirs they must apply their own iteration cap.