Generic filesystem-backed registry skeleton: directory walking, file watching, debounced rescans, user-wins-over-system layering, and a pluggable scan strategy. The single owner of "live-reload a directory tree of user-editable JSON" across the suite.
Walk a set of directories, parse each file (flat *.json, or a metadata-pack subdirectory) into a typed record, watch the directories for live edits, debounce rescans, apply user files that shadow bundled ones with the same ID, and emit one bulk-update signal per scan.
The library factors the work into:
WatchedDirectorySet** is the base mechanism. It owns the QFileSystemWatcher, debounces rescan triggers (50 ms), promotes watches to a parent directory when the target doesn't exist yet, guards against rescan-during-rescan races, and re-arms individual file watches after a scan.IScanStrategy** is the pluggable policy for how to enumerate the registered directories, parse each entry, and commit results to the consumer's registry. The base owns when to scan, and the strategy owns what scanning means.DirectoryLoader** is the flat *.json specialisation that pairs with an IDirectoryLoaderSink (the schema-specific parse + commit strategy). Used by curve and profile loaders.MetadataPackScanStrategy<Payload>** is the templated specialisation for "subdirectory-with-metadata-json" packs. There is one pack per top-level subfolder, validated by a metadata.json, with the payload type chosen by the registry. Hosted by PhosphorRegistry::MetadataPackLoader<T>, which owns the strategy and the watcher and adds the per-id fingerprint diff. Used by `phosphor-shaders`' ShaderRegistry, `phosphor-animation`'s AnimationShaderRegistry, and PhosphorSurfaceShaders::SurfaceShaderRegistry.validateJsonEnvelope** is the shared envelope validation. It parses the file, checks the "name" field is non-empty and matches the filename, and returns a JsonEnvelope carrying the rest of the JSON object for the sink's schema-specific fromJson.A metadata-pack registry does not inherit anything from this library: it COMPOSES a PhosphorRegistry::MetadataPackLoader<T>, which owns the WatchedDirectorySet plus the strategy and provides the search-path management surface (addSearchPath, setUserPath, refresh) every consumer was otherwise hand-rolling.
| Type | Purpose |
|---|---|
PhosphorFsLoader::WatchedDirectorySet | Base mechanism: watcher, debounce, parent-watch, race guard |
PhosphorFsLoader::IScanStrategy | Pluggable enumerate / parse / commit policy |
PhosphorFsLoader::DirectoryLoader | Flat *.json specialisation (sink-driven) |
PhosphorFsLoader::IDirectoryLoaderSink | Per-schema strategy: parseFile() + commitBatch() |
PhosphorFsLoader::ParsedEntry | Parse-result value type with source-path metadata and std::any payload |
PhosphorFsLoader::MetadataPackScanStrategy<P> | Subdirectory-with-metadata.json strategy |
PhosphorFsLoader::validateJsonEnvelope | Shared "name"-field envelope validator returning a JsonEnvelope |
PhosphorFsLoader::resolveWithinDirectory | Containment guard: resolves a declared path against a directory and refuses anything landing outside it |
PhosphorFsLoader::AbsolutePathPolicy | Whether an already-absolute declared path is refused (pack-declared) or trusted (user-chosen at runtime) |
PhosphorFsLoader::SchemaValidator | JSON-Schema gate applied to a pack's metadata.json before it is parsed |
A profile loader sink (flat *.json mode):
#include <PhosphorFsLoader/DirectoryLoader.h>
#include <PhosphorFsLoader/IDirectoryLoaderSink.h>
using namespace PhosphorFsLoader;
class CurveLoaderSink : public IDirectoryLoaderSink {
std::optional<ParsedEntry> parseFile(const QString& filePath) override {
// parse one curve file; nothing registry-side here.
}
void commitBatch(const QStringList& removedKeys,
const QList<ParsedEntry>& currentEntries) override {
// single mutation point; emit one reloadAll() signal here.
}
};
CurveLoaderSink sink{...};
DirectoryLoader loader(sink); // sink is borrowed for the loader's lifetime
loader.loadFromDirectories({systemDir, userDir}, LiveReload::On);
// No requestRescan() needed: loadFromDirectories already ran a full
// synchronous scan and dispatched commitBatch + entriesChanged.A metadata-pack registry (used by shader / animation-shader registries):
// `MyPack` must derive PhosphorRegistry::IFactoryBase — the loader
// static_asserts on it. The registry is OWNED by the consumer and BORROWED by
// the loader, so declare it first.
class MyPackRegistry : public QObject {
Q_OBJECT
public:
MyPackRegistry()
: m_loader(std::make_unique<PhosphorRegistry::MetadataPackLoader<MyPack>>(
&m_registry,
[](const QString& subdir, const QJsonObject& root, bool isUser)
-> std::shared_ptr<MyPack> {
return parseMyPack(subdir, root, isUser);
},
myLogCategory()))
{
m_loader->setOnCommitted([this] { Q_EMIT packsChanged(); });
}
void addSearchPath(const QString& dir) { m_loader->addSearchPath(dir); }
void setUserPath(const QString& dir) { m_loader->setUserPath(dir); }
void refresh() { m_loader->refresh(); }
// … expose payload-typed lookups over m_registry
Q_SIGNALS:
void packsChanged();
private:
PhosphorRegistry::Registry<MyPack> m_registry;
std::unique_ptr<PhosphorRegistry::MetadataPackLoader<MyPack>> m_loader;
};
// Composition root then wires:
registry.addSearchPath(systemDir);
registry.setUserPath(userDir);
registry.refresh();LiveReload::On installs a QFileSystemWatcher on every scanned directory (or its parent, if the target doesn't exist yet, so fresh installs that create the user-data dir later still pick up edits without a restart). The enable is a set-wide ONE-WAY latch: a later LiveReload::Off call never disarms an already-armed set. A test that must not watch should never pass On, or should destroy and rebuild the set.resolveWithinDirectory. There is exactly one correct way to write that check: QDir::filePath returns an absolute argument unchanged and never normalises .., a lexical compare cannot see a symlinked component, and canonicalisation returns empty for a leaf that does not exist yet, which is the ordinary live-reload case. The guard canonicalises the deepest EXISTING ancestor and re-appends the missing tail, so the two domains are never mixed, and it fails closed when nothing on the chain exists.commitBatch is the one mutation point.** The sink only touches its target registry inside commitBatch, so bulk signals (e.g. a QML reloadAll) coalesce to one emit per scan.ParsedEntry::payload is std::any so the loader stays schema-agnostic. The sink produces it, the sink consumes it, nobody in between peeks. The metadata-pack variant adds a typed Payload template parameter for registries that don't need the erasure.QtCore-DPHOSPHORFSLOADER_USE_SYSTEM_VALIJSON=ON to link a system copy instead.ProfileLoader, CurveLoader, and AnimationShaderRegistry are clients.ShaderRegistry composes a MetadataPackLoader over this library's strategy.