Phosphor
Qt6 / Wayland library suite for window-management tools
 
Loading...
Searching...
No Matches
MetadataPackScanStrategy.h
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 fuddlesworth
2// SPDX-License-Identifier: LGPL-2.1-or-later
3
4#pragma once
5
8#include <PhosphorFsLoader/phosphorfsloader_export.h>
9
10#include <QtCore/QByteArray>
11#include <QtCore/QCryptographicHash>
12#include <QtCore/QDateTime>
13#include <QtCore/QDir>
14#include <QtCore/QFile>
15#include <QtCore/QFileInfo>
16#include <QtCore/QHash>
17#include <QtCore/QJsonDocument>
18#include <QtCore/QJsonObject>
19#include <QtCore/QJsonParseError>
20#include <QtCore/QList>
21#include <QtCore/QLoggingCategory>
22#include <QtCore/QSet>
23#include <QtCore/QString>
24#include <QtCore/QStringList>
25
26#include <algorithm>
27#include <functional>
28#include <optional>
29#include <type_traits>
30#include <utility>
31#include <vector>
32
33namespace PhosphorFsLoader {
34
198template<typename Payload>
200{
201 // The strategy hashes `id` into the per-rescan signature and uses it
202 // as the QHash key for first-wins layering — neither works without
203 // a public QString id member. Every production Entry type satisfies
204 // this; bespoke payloads must too.
205 //
206 // `decltype(... .id)` on an lvalue Payload yields `QString&`; strip
207 // the reference before comparing so the assertion fires only when
208 // the field's type itself isn't `QString`.
209 static_assert(std::is_same_v<std::remove_reference_t<decltype(std::declval<Payload&>().id)>, QString>,
210 "MetadataPackScanStrategy<Payload> requires Payload to expose a public 'QString id' member.");
211
212public:
234 static constexpr int kDefaultMaxEntries = 10'000;
235
248 using Parser =
249 std::function<std::optional<Payload>(const QString& subdirPath, const QJsonObject& root, bool isUser)>;
250
255 using PerEntryWatchPaths = std::function<QStringList(const Payload&)>;
256
261 using PerDirectoryWatchPaths = std::function<QStringList(const QString& searchPath)>;
262
266 using PerSubdirSkip = std::function<bool(const QString& subdirName)>;
267
276 using SignatureContrib = std::function<void(QCryptographicHash&, const Payload&)>;
277
283 using OnCommit = std::function<void()>;
284
297 : m_parser(std::move(parser))
298 , m_onCommit(std::move(onCommit))
299 {
300 // An empty `Parser` would silently skip every entry on every
301 // rescan and look like a configuration bug from the outside ("my
302 // packs all disappeared"). Every real consumer always passes a
303 // real parser; assert in debug builds so a future caller doesn't
304 // have to debug an empty registry from a default-constructed
305 // `std::function`. `OnCommit` is allowed to be empty (a consumer
306 // that doesn't care about content-changed signals).
307 Q_ASSERT_X(static_cast<bool>(m_parser), "MetadataPackScanStrategy",
308 "Parser must not be empty — every rescan would silently skip every entry");
309 }
310
311 ~MetadataPackScanStrategy() override = default;
312
315
318 {
319 m_perEntryWatch = std::move(fn);
320 }
321
324 {
325 m_perDirWatch = std::move(fn);
326 }
327
330 {
331 m_subdirSkip = std::move(fn);
332 }
333
336 {
337 m_sigContrib = std::move(fn);
338 }
339
362 void setUserPath(const QString& path)
363 {
364 m_userPath = path;
365 }
366
380 void setMaxEntries(int cap)
381 {
382 Q_ASSERT_X(cap >= 0, "MetadataPackScanStrategy::setMaxEntries", "cap must be non-negative");
383 // Release fallback for a NEGATIVE cap is the DEFAULT, not zero.
384 // Clamping to zero would make the assert's release counterpart
385 // destructive in exactly the way the assert exists to prevent: the cap
386 // trips on the first subdir, `m_packs` rebuilds empty, and `OnCommit`
387 // fires as though every pack had been uninstalled. Falling back to the
388 // default degrades to "the caller's cap was ignored", which is
389 // recoverable and obvious. An explicit zero is honoured — see above.
390 m_maxEntries = cap >= 0 ? cap : kDefaultMaxEntries;
391 }
392
405 void setLoggingCategory(const QLoggingCategory& cat)
406 {
407 m_loggingCat = &cat;
408 }
409
435 QStringList performScan(const QStringList& directoriesInScanOrder) override;
436
437 // ─── Accessors used by the consumer registry ────────────────────────────
438
440 const QHash<QString, Payload>& packsById() const
441 {
442 return m_packs;
443 }
444
448 QList<Payload> packs() const
449 {
450 QList<Payload> sorted = m_packs.values();
451 std::sort(sorted.begin(), sorted.end(), [](const Payload& a, const Payload& b) {
452 return a.id < b.id;
453 });
454 return sorted;
455 }
456
461 QStringList packIds() const
462 {
463 QStringList ids = m_packs.keys();
464 std::sort(ids.begin(), ids.end());
465 return ids;
466 }
467
470 Payload pack(const QString& id) const
471 {
472 return m_packs.value(id);
473 }
474
476 bool contains(const QString& id) const
477 {
478 return m_packs.contains(id);
479 }
480
482 int size() const
483 {
484 return m_packs.size();
485 }
486
487private:
488 Parser m_parser;
489 OnCommit m_onCommit;
490 PerEntryWatchPaths m_perEntryWatch;
491 PerDirectoryWatchPaths m_perDirWatch;
492 PerSubdirSkip m_subdirSkip;
493 SignatureContrib m_sigContrib;
494
495 QString m_userPath;
496 int m_maxEntries = kDefaultMaxEntries;
497 const QLoggingCategory* m_loggingCat = nullptr;
498
499 QHash<QString, Payload> m_packs;
500 QByteArray m_lastSignature;
501 bool m_signatureSeeded = false;
502};
503
504// ─── Template implementation ─────────────────────────────────────────────────
505
506namespace detail {
507
510PHOSPHORFSLOADER_EXPORT Q_DECLARE_LOGGING_CATEGORY(lcMetadataPackScan)
511
512} // namespace detail
513
514template<typename Payload>
515QStringList MetadataPackScanStrategy<Payload>::performScan(const QStringList& directoriesInScanOrder)
516{
517 // Release-build counterpart to the ctor's `Q_ASSERT_X(m_parser)`. An empty
518 // `std::function` would throw `std::bad_function_call` out of the parse
519 // loop below, on the GUI thread, from inside a filesystem-watch callback.
520 // Refusing the whole scan instead leaves the previously registered packs
521 // in place and logs something an operator can grep for.
522 if (!m_parser) {
523 // NOTE the second-order consequence of the empty return: the caller
524 // (`WatchedDirectorySet::rescanAll`) feeds it straight into
525 // `syncFileWatches`, which removes every per-file watch not in the
526 // returned list — so a parserless strategy not only keeps the
527 // previously registered packs frozen, it silently disarms live
528 // reload for them, permanently (there is no parser setter, so the
529 // condition never clears for the strategy's lifetime).
530 qCWarning(m_loggingCat ? *m_loggingCat : detail::lcMetadataPackScan())
531 << "MetadataPackScanStrategy: no parser configured, skipping scan";
532 return {};
533 }
534
535 // Per-entry filesystem fingerprint (`metadata.json` size+mtime +
536 // `isUser`) captured during the parse loop and mixed into the SHA-1
537 // signature below. Decoupled from `Payload` so the strategy can
538 // fingerprint facts the parser doesn't necessarily store, without
539 // requiring Payload to expose them.
540 struct EntryFingerprint
541 {
542 qint64 metadataSize = 0;
543 qint64 metadataMtimeMs = 0;
544 bool isUser = false;
545 };
546 // Single accumulator holding everything we need about an entry: id
547 // (for the signature key + final map key), fingerprint, payload.
548 // Sorted-by-id at the end of the parse pass so signature mixing and
549 // accessor population both consume a stable order without paying
550 // the cost of a parallel `QHash<QString, EntryFingerprint>`.
551 struct Entry
552 {
553 QString id;
554 EntryFingerprint fp;
555 Payload payload;
556 };
557 std::vector<Entry> entries;
558 // Tracks claimed ids for first-wins collision detection during the
559 // parse loop. Cheaper than a parallel `QHash<QString, Payload>` —
560 // one O(1) key insert per entry, no payload duplication.
561 QSet<QString> seenIds;
562
563 QStringList desiredWatches;
564
565 const QLoggingCategory& log = m_loggingCat ? *m_loggingCat : detail::lcMetadataPackScan();
566
567 // Resolve the user path's canonical form once per rescan. Empty
568 // (no user path configured, or the path doesn't exist yet) yields
569 // `false` for every dir — the iterated-dir compare below short-
570 // circuits when this is empty. Canonicalised once here, then
571 // compared per-search-path inside the OUTER loop only — the inner
572 // subdir loop never canonicalises, so cost stays O(searchPaths).
573 const QString canonicalUserPath = m_userPath.isEmpty() ? QString() : QFileInfo(m_userPath).canonicalFilePath();
574
575 bool capTripped = false;
584 int subdirsConsidered = 0;
585
586 // Reverse-iterate: highest-priority dirs first, first-wins on id
587 // collision. The base normalises caller input into the canonical
588 // `[lowest, ..., highest]` shape at registration time, so this
589 // reversal is the SSOT for the user-wins semantic the two
590 // consumer registries promise.
591 for (auto dirIt = directoriesInScanOrder.crbegin(); dirIt != directoriesInScanOrder.crend() && !capTripped;
592 ++dirIt) {
593 const QString& searchPath = *dirIt;
594 QDir dirObj(searchPath);
595 if (!dirObj.exists()) {
596 qCDebug(log) << "MetadataPackScanStrategy: search path does not exist:" << searchPath;
597 continue;
598 }
599
600 const bool isUserDir =
601 !canonicalUserPath.isEmpty() && QFileInfo(searchPath).canonicalFilePath() == canonicalUserPath;
602
603 // Per-search-path watch additions (top-level shared files —
604 // shader-pack registry watches `*.glsl` includes here). These sit
605 // OUTSIDE the entry cap: the cap charges per-subdir work, and a
606 // per-directory extractor returning an unbounded glob grows the
607 // watch list with nothing bounding it. Acceptable because the
608 // extractor is trusted first-party code (unlike the pack subdirs,
609 // which are user-writable data), but an extractor author adding a
610 // recursive glob here should know the cap does not cover it.
611 if (m_perDirWatch) {
612 desiredWatches.append(m_perDirWatch(searchPath));
613 }
614
615 // Symlinked pack directories are FOLLOWED, with no canonical-
616 // containment check. Symlinking a pack directory out of a dotfiles repo
617 // or a shared drive is the normal way people manage them, and the
618 // threat model here is same-user: anything a symlink reaches, the user
619 // could have copied in.
620 //
621 // This is NOT the claim that a pack is inert. Every production consumer
622 // is a shader registry, and a pack's metadata NAMES source files that
623 // are compiled and run on the GPU — so the containment that matters is
624 // on the paths a pack DECLARES, not on the directory it was found
625 // through, and it belongs in the parser that resolves them.
626 // PluginLoader is the counter-example in this library and uses
627 // QDir::NoSymLinks, because a plugin subdir leads to a dlopen.
628 const QStringList subdirs = dirObj.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
629 for (const QString& subdir : subdirs) {
630 // Per-rescan DoS guard, counting subdirs considered rather than
631 // entries registered. Reverse-iteration scans user-first /
632 // system-last, so cap-trip drops *system* overflow rather than
633 // user overrides.
634 //
635 // Checked BEFORE the watch is armed below, not after: the watch
636 // list is the other unbounded quantity here, and a spray of
637 // broken packs that never register would otherwise grow it until
638 // the inotify per-user watch limit is exhausted.
639 //
640 // Also before the skip predicate, so a skipped subdir is still
641 // CHARGED. Every sibling scanner charges before its own first
642 // filter, for the same reason: a caller-supplied predicate that
643 // inspects the name is work a spray can buy, and "considered" has
644 // to mean every entry the loop reaches or the cap bounds something
645 // other than what its docs claim.
646 if (subdirsConsidered >= m_maxEntries) {
647 capTripped = true;
648 break;
649 }
650 ++subdirsConsidered;
651 if (m_subdirSkip && m_subdirSkip(subdir)) {
652 continue;
653 }
654
655 const QString subdirPath = dirObj.filePath(subdir);
656 const QString metadataPath = subdirPath + QStringLiteral("/metadata.json");
657
658 // Always re-arm the metadata.json watch — even if parsing
659 // fails or the id collides. An edit that fixes a broken
660 // metadata.json is the most common way an entry transitions
661 // from invisible to visible; we want to wake on it.
662 desiredWatches.append(metadataPath);
663
664 const QFileInfo metadataInfo(metadataPath);
665 // `isFile()`, not `exists()`. A FIFO in a user-writable pack dir
666 // satisfies exists(), and `QFile::open(ReadOnly)` on a FIFO BLOCKS
667 // until a writer appears — indefinitely, on the GUI thread, and the
668 // size cap cannot help because a FIFO reports size 0. The two sibling
669 // scanners are immune only incidentally: they enumerate with
670 // QDir::Files, which excludes a FIFO, whereas this one constructs the
671 // path. A directory named metadata.json is refused by the same check.
672 if (!metadataInfo.isFile()) {
673 // Watch the SUBDIRECTORY instead. `QFileSystemWatcher` cannot
674 // watch a path that does not exist, so the metadata.json entry
675 // appended above arms nothing here — and only the registered
676 // SEARCH paths are directory-watched, not the pack dirs under
677 // them. Without this, `cp -r mypack <packs>/` races: the mkdir
678 // wakes the debounced rescan, the rescan finds an empty (or
679 // half-copied) subdir, and the metadata.json landing afterwards
680 // fires no event at all, so the pack stays invisible until
681 // something unrelated triggers a rescan. Watching the subdir
682 // makes the file's creation the wake-up.
683 desiredWatches.append(subdirPath);
684 qCDebug(log) << "MetadataPackScanStrategy: skipping subdir, no metadata.json:" << subdirPath;
685 continue;
686 }
687
688 // DoS guard: untrusted same-user metadata.json must not
689 // stall the GUI thread with a 2 GB blob. Reuse
690 // `DirectoryLoader::kMaxFileBytes` as the SSOT — same cap
691 // the sister `JsonScanStrategy` enforces on every JSON
692 // file it loads.
693 if (metadataInfo.size() > DirectoryLoader::kMaxFileBytes) {
694 qCWarning(log) << "MetadataPackScanStrategy: skipping oversized metadata.json:" << metadataPath << "("
695 << metadataInfo.size() << "bytes, cap" << DirectoryLoader::kMaxFileBytes << ")";
696 continue;
697 }
698
699 QFile file(metadataPath);
700 if (!file.open(QIODevice::ReadOnly)) {
701 qCWarning(log) << "MetadataPackScanStrategy: failed to open metadata.json:" << metadataPath;
702 continue;
703 }
704 // Re-checked on the OPEN descriptor. The pre-open stat above can be
705 // beaten by a rewrite between the stat and the open, which leaves
706 // `readAll()` below unbounded — the same TOCTOU that was closed in
707 // `validateJsonEnvelope`, and this was the one remaining site that
708 // still enforced the cap on the path rather than the descriptor.
709 //
710 // Deliberately untested: hitting this branch requires losing the
711 // stat/open race on purpose, and a test cannot schedule a
712 // same-process rewrite into that window deterministically. The
713 // pre-open cap two blocks up carries the observable coverage; this
714 // recheck is the race-closing twin and is kept correct by review.
715 if (file.size() > DirectoryLoader::kMaxFileBytes) {
716 qCWarning(log) << "MetadataPackScanStrategy: skipping oversized metadata.json:" << metadataPath << "("
717 << file.size() << "bytes, cap" << DirectoryLoader::kMaxFileBytes << ")";
718 continue;
719 }
720
721 QJsonParseError parseError{};
722 const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
723 if (parseError.error != QJsonParseError::NoError) {
724 qCWarning(log) << "MetadataPackScanStrategy: parse error in" << metadataPath << ":"
725 << parseError.errorString();
726 continue;
727 }
728 if (!doc.isObject()) {
729 qCWarning(log) << "MetadataPackScanStrategy: non-object root in" << metadataPath;
730 continue;
731 }
732
733 // Schema-specific parse. `m_parser` is guarded once at the top of
734 // this function rather than here, so the ctor's debug assert has a
735 // release-build counterpart and this call cannot throw
736 // `std::bad_function_call` out of a GUI-thread rescan.
737 std::optional<Payload> parsed = m_parser(subdirPath, doc.object(), isUserDir);
738 if (!parsed.has_value()) {
739 qCDebug(log) << "MetadataPackScanStrategy: parser declined" << metadataPath;
740 continue;
741 }
742 if (parsed->id.isEmpty()) {
743 qCWarning(log) << "MetadataPackScanStrategy: skipping" << metadataPath << ": empty 'id' field";
744 continue;
745 }
746
747 // First-wins on id collision. Reverse-iteration means a
748 // user-dir entry claims its id before any system-dir entry
749 // can; a colliding system entry is silently shadowed.
750 if (seenIds.contains(parsed->id)) {
751 qCDebug(log) << "MetadataPackScanStrategy: id" << parsed->id
752 << "already registered from a higher-priority dir; shadowed at:" << subdirPath;
753 continue;
754 }
755
756 // Per-payload watches — frag/vert/kwin shaders, etc.
757 if (m_perEntryWatch) {
758 desiredWatches.append(m_perEntryWatch(*parsed));
759 }
760
761 // Capture the metadata.json fingerprint BEFORE the move so
762 // we can mix it into the per-rescan signature below. Any
763 // parser-consumed field's edit shifts the file's mtime (POSIX
764 // guarantees the mtime is UPDATED on a content-change write;
765 // the value mixed here is truncated to milliseconds, so a
766 // same-size rewrite landing in the same millisecond as the
767 // prior stat is theoretically invisible — accepted, because
768 // the alternative is content-hashing every file on every
769 // rescan, and every production save path here goes through an
770 // atomic rename which also changes the inode), so a single
771 // mtime+size mix-in covers every schema field without forcing
772 // per-field enumeration in SignatureContrib.
773 QString id = parsed->id;
774 seenIds.insert(id);
775 entries.push_back(
776 Entry{std::move(id),
777 EntryFingerprint{metadataInfo.size(), metadataInfo.lastModified().toMSecsSinceEpoch(), isUserDir},
778 std::move(*parsed)});
779 }
780 }
781
782 if (capTripped) {
783 qCWarning(log).nospace() << "MetadataPackScanStrategy: reached entry cap (" << m_maxEntries
784 << ") — later entries skipped to protect the GUI thread. Prune the watched search "
785 "paths or raise the cap.";
786 // The truncated set IS still committed, so `MetadataPackLoader::
787 // reconcile` unregisters every pack that sorted past the cap even
788 // though it is still on disk. That is deliberate here and differs from
789 // PluginLoader, which skips its removal sweep on a cap trip: a pack
790 // registry's accessors are queried by id from QML on every frame, and
791 // leaving entries registered that this scan never validated would serve
792 // stale payloads indefinitely. Dropping them is visible and recoverable
793 // (prune the search path, or raise the cap, and the next scan restores
794 // them); serving a stale pack is neither.
795 }
796
797 // Sort by id once. Stable hash + stable accessor ordering both fall
798 // out of this single pass — no parallel structures, no second
799 // QHash::keys() + sort.
800 std::sort(entries.begin(), entries.end(), [](const Entry& a, const Entry& b) {
801 return a.id < b.id;
802 });
803
804 // SHA-1 signature in two passes.
805 //
806 // Pass 1 — per-entry attribution. (id, isUser, metadata.json size+mtime,
807 // payload-specific bytes via SignatureContrib). Stable iteration order
808 // is the sorted entries vec; QHash randomisation never leaks into the
809 // signature.
810 //
811 // Pass 2 — watch-set auto-fingerprint. Every distinct file the
812 // strategy is watching (the per-entry metadata.json plus everything
813 // returned by `PerEntryWatchPaths` and `PerDirectoryWatchPaths`)
814 // contributes path|size|mtime|. This is load-bearing for change-only
815 // emit completeness: any file the watcher fires on must shift the
816 // signature, otherwise the rescan runs but `OnCommit` stays silent
817 // and consumers hold stale state. Without this pass, top-level
818 // shared `*.glsl` includes (`common.glsl`, `audio.glsl`, …) and
819 // per-pack auxiliary files (`helpers.glsl`, etc.) would fire the
820 // watcher but not the consumer's content-changed signal.
821 //
822 // The metadata.json mtime+size shows up in BOTH passes (per-entry +
823 // watch-set). Deterministic redundancy — costs nothing, keeps the
824 // per-entry attribution clean.
825 QCryptographicHash hasher(QCryptographicHash::Sha1);
826 for (const Entry& e : entries) {
827 hasher.addData(e.id.toUtf8());
828 hasher.addData(QByteArrayView("|"));
829 hasher.addData(e.fp.isUser ? QByteArrayView("u") : QByteArrayView("s"));
830 hasher.addData(QByteArrayView("|"));
831 hasher.addData(QByteArray::number(e.fp.metadataSize));
832 hasher.addData(QByteArrayView("|"));
833 hasher.addData(QByteArray::number(e.fp.metadataMtimeMs));
834 hasher.addData(QByteArrayView("|"));
835 if (m_sigContrib) {
836 m_sigContrib(hasher, e.payload);
837 }
838 hasher.addData(QByteArrayView("\n"));
839 }
840 // Watch-set pass. Sorted + deduped for deterministic ordering across
841 // rescans regardless of the order paths were appended during the
842 // outer / inner loops above. The sort/dedup itself is cheap, but note the
843 // list length is NOT bounded by m_maxEntries: the per-directory and
844 // per-entry watch extractors return arbitrary-length globs (see the
845 // "sit OUTSIDE the entry cap" note earlier in this file). The real
846 // per-rescan cost is the QFileInfo stat (exists/size/lastModified) in the
847 // loop below, one per watch path, on the GUI thread on every debounced
848 // fire — accepted as the dominant syscall cost of a rescan.
849 QStringList sortedWatches = desiredWatches;
850 sortedWatches.removeDuplicates();
851 std::sort(sortedWatches.begin(), sortedWatches.end());
852 for (const QString& path : sortedWatches) {
853 hasher.addData(path.toUtf8());
854 hasher.addData(QByteArrayView("|"));
855 const QFileInfo fi(path);
856 if (fi.exists()) {
857 hasher.addData(QByteArray::number(fi.size()));
858 hasher.addData(QByteArrayView("|"));
859 hasher.addData(QByteArray::number(fi.lastModified().toMSecsSinceEpoch()));
860 } else {
861 // Stable sentinel for absent files. `lastModified()` on an
862 // invalid datetime is implementation-defined — explicit
863 // "missing" keeps the hash format portable across Qt
864 // versions and filesystems.
865 hasher.addData(QByteArrayView("missing"));
866 }
867 hasher.addData(QByteArrayView("\n"));
868 }
869 const QByteArray signature = hasher.result();
870
871 QHash<QString, Payload> fresh;
872 fresh.reserve(static_cast<int>(entries.size()));
873 for (Entry& e : entries) {
874 fresh.insert(e.id, std::move(e.payload));
875 }
876
877 const bool isFirstScan = !m_signatureSeeded;
878 const bool changed = isFirstScan ? !fresh.isEmpty() : signature != m_lastSignature;
879
880 m_packs = std::move(fresh);
881 m_lastSignature = signature;
882 m_signatureSeeded = true;
883
884 if (changed && m_onCommit) {
885 m_onCommit();
886 }
887
888 // Return the deduped + lex-sorted watch set rather than the raw
889 // append-order `desiredWatches`. The caller (`WatchedDirectorySet`)
890 // dedupes again internally via `QSet<QString> m_watchedFiles`, so
891 // this isn't a correctness fix — but the strategy already paid for
892 // dedup + sort during the signature pass, so handing the cleaned
893 // list back saves the watcher its own dedup pass, gives the signature
894 // pass a deterministic input, and lets a test assert on the returned
895 // list directly. (It does NOT make the watcher's own diagnostics
896 // deterministic — syncFileWatches copies into a QSet and iterates that.)
897 // Costs nothing.
898 return sortedWatches;
899}
900
901} // namespace PhosphorFsLoader
static constexpr qint64 kMaxFileBytes
Per-file size cap — alias of the canonical PhosphorFsLoader::kMaxJsonFileBytes (see FileLimits....
Definition DirectoryLoader.h:161
Pluggable enumeration / parse / commit policy for WatchedDirectorySet.
Definition IScanStrategy.h:63
Reusable scan strategy for metadata.json-driven subdirectory pack registries.
Definition MetadataPackScanStrategy.h:200
MetadataPackScanStrategy & operator=(const MetadataPackScanStrategy &)=delete
void setUserPath(const QString &path)
Set the user-data search path used for isUser classification.
Definition MetadataPackScanStrategy.h:362
QList< Payload > packs() const
Live entries sorted by id for deterministic ordering.
Definition MetadataPackScanStrategy.h:448
bool contains(const QString &id) const
True if id is registered.
Definition MetadataPackScanStrategy.h:476
void setPerDirectoryWatchPaths(PerDirectoryWatchPaths fn)
Set the per-directory watch-extractor. Default: empty list.
Definition MetadataPackScanStrategy.h:323
MetadataPackScanStrategy(Parser parser, OnCommit onCommit)
Construct with the parser + commit hook (the two always-required policies).
Definition MetadataPackScanStrategy.h:296
const QHash< QString, Payload > & packsById() const
Live entries by id.
Definition MetadataPackScanStrategy.h:440
void setPerSubdirSkip(PerSubdirSkip fn)
Set the per-subdir-name skip predicate. Default: never skip.
Definition MetadataPackScanStrategy.h:329
void setPerEntryWatchPaths(PerEntryWatchPaths fn)
Set the per-payload watch-extractor. Default: empty list.
Definition MetadataPackScanStrategy.h:317
std::function< bool(const QString &subdirName)> PerSubdirSkip
Optional: predicate skipping subdirectories whose bare name matches a sentinel.
Definition MetadataPackScanStrategy.h:266
MetadataPackScanStrategy(const MetadataPackScanStrategy &)=delete
QStringList performScan(const QStringList &directoriesInScanOrder) override
Run a full rescan across directoriesInScanOrder.
Definition MetadataPackScanStrategy.h:515
Payload pack(const QString &id) const
Lookup by id.
Definition MetadataPackScanStrategy.h:470
void setMaxEntries(int cap)
Per-rescan entry cap.
Definition MetadataPackScanStrategy.h:380
void setLoggingCategory(const QLoggingCategory &cat)
Override the logging category used for the strategy's own warnings (cap trip, oversized metadata....
Definition MetadataPackScanStrategy.h:405
std::function< std::optional< Payload >(const QString &subdirPath, const QJsonObject &root, bool isUser)> Parser
Parse one metadata.json into a payload.
Definition MetadataPackScanStrategy.h:249
std::function< void()> OnCommit
Synchronous "the discovered set changed" hook.
Definition MetadataPackScanStrategy.h:283
QStringList packIds() const
Live entry ids in lexicographic order.
Definition MetadataPackScanStrategy.h:461
static constexpr int kDefaultMaxEntries
Hard cap on subdirs CONSIDERED per rescan, summed across every registered search path — not on the en...
Definition MetadataPackScanStrategy.h:234
std::function< void(QCryptographicHash &, const Payload &)> SignatureContrib
Optional: payload-specific bytes to fan into the per-rescan SHA-1 signature.
Definition MetadataPackScanStrategy.h:276
std::function< QStringList(const Payload &)> PerEntryWatchPaths
Extract the per-payload paths the base must re-arm individual file watches on after every rescan.
Definition MetadataPackScanStrategy.h:255
std::function< QStringList(const QString &searchPath)> PerDirectoryWatchPaths
Optional: per-search-path watch additions beyond per-pack extraction.
Definition MetadataPackScanStrategy.h:261
void setSignatureContrib(SignatureContrib fn)
Set the payload-specific signature contributor. Default: contributes nothing.
Definition MetadataPackScanStrategy.h:335
int size() const
Number of currently registered packs.
Definition MetadataPackScanStrategy.h:482
Definition DirectoryLoader.h:19