Skip to content

Delegate profile/command JSON persistence to ktsu.AppDataStorage #93

Description

@matt-edmondson

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions