What's hand-rolled
JsonKeybindingRepository hand-rolls a JSON-file-per-collection store under the application-data folder:
KeybindingManagerFactory.CreateManager() resolves the default location itself: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Keybinding").
JsonKeybindingRepository then does its own read/write/corruption handling against that directory for three separate files (profiles.json, commands.json, active-profile.json): File.WriteAllTextAsync/File.ReadAllTextAsync (SaveProfileAsync/LoadAllProfilesInternalAsync, similarly for commands at L153-L198 and the active-profile flag at L201-L235), a hand-written Directory.Exists/Directory.CreateDirectory init step (L241-L249), and catch (JsonException) blocks that silently drop back to an empty collection on a corrupt file (e.g. L116-L120, L193-L197, L230-L234) with no backup or recovery attempt.
What ktsu.AppDataStorage provides
AppData<T> is exactly this: a base class for a POCO that is loaded from and saved to a JSON file under %AppData%/<subdirectory>, resolved through ktsu.Semantics.Paths rather than a raw Environment.GetFolderPath + Path.Combine.
public static T LoadOrCreate(RelativeDirectoryPath? subdirectory, FileName? fileName) (AppData.cs:498) loads an existing file or creates and saves a fresh instance — the exact shape of LoadAllProfilesInternalAsync's "read, or start empty" logic, but atomic: AppData.WriteText (AppData.cs:142) writes to a .tmp file, backs up the previous file to .bk, then swaps them in, rather than writing the target file directly.
- On a corrupt file,
LoadOrCreate (AppData.cs:516-530) deletes the bad file and retries, which lets AppData.ReadText's own fallback (AppData.cs:176-211) recover from the .bk backup instead of silently returning an empty collection — JsonKeybindingRepository has no backup file at all, so a corrupt profiles.json currently just loses every profile.
public void Save() (AppData.cs:436) serializes the instance and calls the same atomic WriteText.
Three AppData<T> subclasses (e.g. ProfilesData, CommandsData, ActiveProfileData, each wrapping the same DTO shapes JsonKeybindingRepository already defines) would cover the three files, called through AppData<T>.LoadOrCreate(subdirectory) — the static overload that takes an explicit subdirectory, not the cached Get()/InternalState singleton (see Caveats).
Why it's worth it
The win here is the backup/recovery path, not line count: AppData<T>.Save()/LoadOrCreate() keep a .bk copy and a timestamped backup on corruption recovery, so a truncated write or a corrupted file doesn't silently drop a user's profiles the way the current catch (JsonException) { return []; } blocks do. The three near-identical DTO/save/load blocks in JsonKeybindingRepository would also collapse to three small model classes.
Compatibility
- Subject (
Keybinding.Core) targets: net8.0;net9.0;net10.0
ktsu.AppDataStorage targets: net10.0;net9.0;net8.0;net7.0;netstandard2.0;netstandard2.1
- Dependency direction:
ktsu.AppDataStorage's Directory.Packages.props depends only on ktsu.Semantics.Paths, ktsu.Semantics.Strings, ktsu.RoundTripStringJsonConverter, and ktsu.CaseConverter — no dependency on this repo, directly or transitively.
Sketch
// before: Keybinding.Core/Services/JsonKeybindingRepository.cs
string profilesPath = Path.Combine(_dataDirectory, ProfilesFileName);
string json = JsonSerializer.Serialize(profileDtos, _jsonOptions);
await File.WriteAllTextAsync(profilesPath, json).ConfigureAwait(false);
// ...and a matching hand-written read+catch(JsonException) path
// after
internal sealed class ProfilesData : AppData<ProfilesData>
{
public List<ProfileDto> Profiles { get; set; } = [];
}
ProfilesData data = AppData<ProfilesData>.LoadOrCreate(RelativeDirectoryPath.Create("Keybinding"));
data.Profiles = profileDtos.ToList();
data.Save();
Caveats
AppData<T>'s process-wide Get()/InternalState accessor caches a single instance per type T in a static Lazy<T> guarded by a static Lock. JsonKeybindingRepository is constructed with an arbitrary _dataDirectory per instance (the test suite creates a fresh temp directory per test), so adopting this would mean calling the LoadOrCreate(subdirectory, fileName) overload directly and holding the returned instance in the repository object yourself, rather than using Get() — workable, but the Lock object is still shared across every ProfilesData instance in the process regardless of directory, which is a minor contention difference from today's per-repository-instance state.
AppData<T>.FilePath is namespaced under AppData.Path (%AppData%/<AppDomain.CurrentDomain.FriendlyName>) by default, joined with the subdirectory argument — passing RelativeDirectoryPath.Create("Keybinding") reproduces today's %AppData%/Keybinding layout, but this is worth double-checking against any existing user installs before changing the file layout.
- The JSON options differ:
AppData always installs RoundTripStringJsonConverterFactory and ReferenceHandler.Preserve (AppData.cs:36-46) on top of JsonStringEnumConverter, versus the repository's plain CamelCase + enum-converter options — the DTOs would need checking against those extra converters, though none of the three DTOs currently hold a SemanticString or reference cycle that ReferenceHandler.Preserve would change the wire format for.
What's hand-rolled
JsonKeybindingRepositoryhand-rolls a JSON-file-per-collection store under the application-data folder:KeybindingManagerFactory.CreateManager()resolves the default location itself:Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Keybinding").JsonKeybindingRepositorythen does its own read/write/corruption handling against that directory for three separate files (profiles.json,commands.json,active-profile.json):File.WriteAllTextAsync/File.ReadAllTextAsync(SaveProfileAsync/LoadAllProfilesInternalAsync, similarly for commands at L153-L198 and the active-profile flag at L201-L235), a hand-writtenDirectory.Exists/Directory.CreateDirectoryinit step (L241-L249), andcatch (JsonException)blocks that silently drop back to an empty collection on a corrupt file (e.g. L116-L120, L193-L197, L230-L234) with no backup or recovery attempt.What ktsu.AppDataStorage provides
AppData<T>is exactly this: a base class for a POCO that is loaded from and saved to a JSON file under%AppData%/<subdirectory>, resolved throughktsu.Semantics.Pathsrather than a rawEnvironment.GetFolderPath+Path.Combine.public static T LoadOrCreate(RelativeDirectoryPath? subdirectory, FileName? fileName)(AppData.cs:498) loads an existing file or creates and saves a fresh instance — the exact shape ofLoadAllProfilesInternalAsync's "read, or start empty" logic, but atomic:AppData.WriteText(AppData.cs:142) writes to a.tmpfile, backs up the previous file to.bk, then swaps them in, rather than writing the target file directly.LoadOrCreate(AppData.cs:516-530) deletes the bad file and retries, which letsAppData.ReadText's own fallback (AppData.cs:176-211) recover from the.bkbackup instead of silently returning an empty collection —JsonKeybindingRepositoryhas no backup file at all, so a corruptprofiles.jsoncurrently just loses every profile.public void Save()(AppData.cs:436) serializes the instance and calls the same atomicWriteText.Three
AppData<T>subclasses (e.g.ProfilesData,CommandsData,ActiveProfileData, each wrapping the same DTO shapesJsonKeybindingRepositoryalready defines) would cover the three files, called throughAppData<T>.LoadOrCreate(subdirectory)— the static overload that takes an explicit subdirectory, not the cachedGet()/InternalStatesingleton (see Caveats).Why it's worth it
The win here is the backup/recovery path, not line count:
AppData<T>.Save()/LoadOrCreate()keep a.bkcopy and a timestamped backup on corruption recovery, so a truncated write or a corrupted file doesn't silently drop a user's profiles the way the currentcatch (JsonException) { return []; }blocks do. The three near-identical DTO/save/load blocks inJsonKeybindingRepositorywould also collapse to three small model classes.Compatibility
Keybinding.Core) targets:net8.0;net9.0;net10.0ktsu.AppDataStoragetargets:net10.0;net9.0;net8.0;net7.0;netstandard2.0;netstandard2.1ktsu.AppDataStorage'sDirectory.Packages.propsdepends only onktsu.Semantics.Paths,ktsu.Semantics.Strings,ktsu.RoundTripStringJsonConverter, andktsu.CaseConverter— no dependency on this repo, directly or transitively.Sketch
Caveats
AppData<T>'s process-wideGet()/InternalStateaccessor caches a single instance per typeTin astatic Lazy<T>guarded by astatic Lock.JsonKeybindingRepositoryis constructed with an arbitrary_dataDirectoryper instance (the test suite creates a fresh temp directory per test), so adopting this would mean calling theLoadOrCreate(subdirectory, fileName)overload directly and holding the returned instance in the repository object yourself, rather than usingGet()— workable, but theLockobject is still shared across everyProfilesDatainstance in the process regardless of directory, which is a minor contention difference from today's per-repository-instance state.AppData<T>.FilePathis namespaced underAppData.Path(%AppData%/<AppDomain.CurrentDomain.FriendlyName>) by default, joined with thesubdirectoryargument — passingRelativeDirectoryPath.Create("Keybinding")reproduces today's%AppData%/Keybindinglayout, but this is worth double-checking against any existing user installs before changing the file layout.AppDataalways installsRoundTripStringJsonConverterFactoryandReferenceHandler.Preserve(AppData.cs:36-46) on top ofJsonStringEnumConverter, versus the repository's plainCamelCase+ enum-converter options — the DTOs would need checking against those extra converters, though none of the three DTOs currently hold aSemanticStringor reference cycle thatReferenceHandler.Preservewould change the wire format for.