Files
HellionChat/HellionChat/Themes/ThemeRegistry.cs
T

678 lines
28 KiB
C#

using System.Text.Json;
using HellionChat.Themes.Builtin;
using Microsoft.Extensions.Logging;
namespace HellionChat.Themes;
public sealed class ThemeRegistry
{
private readonly ILogger<ThemeRegistry>? _logger;
public const string DefaultSlug = HellionArctic.Slug;
// 1Hz throttle for the v1.4.8 B2 auto-refresh-on-active path. The
// Plugin.Draw hook calls RefreshActiveIfStale every frame, but the
// actual File.GetLastWriteTimeUtc disk-stat only runs once per second
// -- 60fps would otherwise mean 3600 stats/min on the same path (more
// on Wine). Same idiom as the StatusBar 1Hz cache.
private const long ActiveStampPollIntervalMs = 1000;
private readonly Dictionary<string, Theme> _builtIns;
private readonly Dictionary<string, (Theme Theme, DateTime Stamp)> _customCache = new(
StringComparer.OrdinalIgnoreCase
);
private readonly string? _customThemesDir;
private Theme _active;
// v1.4.8 B2: source path of the currently active custom theme. Captured
// at Switch() time so RefreshActiveIfStale does not have to reconstruct
// a filename from the slug -- custom theme filenames are not required
// to match the slug they declare in the JSON body. Null when the active
// theme is built-in or no custom-themes directory is configured.
private string? _activeCustomPath;
private long _lastActiveStampCheckMs = -ActiveStampPollIntervalMs;
private DateTime _lastActiveStamp = DateTime.MinValue;
// PM-1 crossfade state. Switch() captures the previous AbgrCache as a
// VALUE-COPY (not a Theme reference) -- the built-in singletons share
// their RecomputeAbgrCache identity, so a reference would mutate
// alongside the new active. _crossfadeStartTickMs == long.MinValue
// means "no crossfade armed yet"; the field stays MinValue after
// SwitchSilent so the plugin-load init-path does not trigger a fade.
private ThemeAbgrCache? _previousAbgrSnapshot;
private long _crossfadeStartTickMs = long.MinValue;
private const int CrossfadeDurationMs = 300;
private Theme? _editingThemeBuffer;
public Theme? EditingThemeBuffer => _editingThemeBuffer;
public event Action? OnEditingBufferChanged;
// Fired after _active changes (Switch / RefreshActiveIfStale); the init host
// wires it to the font-atlas rebuild. NOT fired by SwitchSilent (boot handles that).
private Action? _onActiveChanged;
internal void SetActiveChangedCallback(Action callback) => _onActiveChanged = callback;
// Shared slug guard for any code path that turns a slug into a filename.
// Both SaveEditingBuffer (F1) and ImportFromPath (M6) call this so the
// path-traversal/invalid-char rules live in exactly one place.
//
// Whitespace rejection is intentional: Path.GetInvalidFileNameChars on
// POSIX only flags NUL and '/', so a slug like "foo bar" would pass the
// platform check yet break URL-safety and cross-platform portability.
// Slugs are user-visible identifiers that may end up in filenames on
// Windows + Linux, in config keys, and in JSON — keeping them whitespace-
// free dodges the whole class of "did the user mean this or that" bugs.
internal static bool IsSafeThemeSlug(string? slug)
{
if (string.IsNullOrWhiteSpace(slug))
return false;
foreach (var c in slug)
{
if (char.IsWhiteSpace(c))
return false;
}
return !slug.Contains("..", StringComparison.Ordinal)
&& !slug.Contains('/')
&& !slug.Contains('\\')
&& slug.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
}
public ThemeRegistry(string? customThemesDir = null, ILogger<ThemeRegistry>? logger = null)
{
_logger = logger;
// Insertion order drives the Theme-Picker grid layout (3 columns).
// Row 1: blue family. Row 2: purple to magenta family.
// Row 3: green / warm / classic. Row 4: Synthwave Sunset as a
// retro bonus on its own line.
_builtIns = new Dictionary<string, Theme>(StringComparer.OrdinalIgnoreCase)
{
{ HellionArctic.Slug, HellionArctic.Build() },
{ HellionSpectrum.Slug, HellionSpectrum.Build() },
{ NightBlue.Slug, NightBlue.Build() },
{ EventHorizon.Slug, EventHorizon.Build() },
{ IndigoViolet.Slug, IndigoViolet.Build() },
{ CrystalNocturne.Slug, CrystalNocturne.Build() },
{ MintGrove.Slug, MintGrove.Build() },
{ ForgeMerchantman.Slug, ForgeMerchantman.Build() },
{ Chat2Classic.Slug, Chat2Classic.Build() },
{ SynthwaveSunset.Slug, SynthwaveSunset.Build() },
};
// Centralised so Build() factories stay free of cache plumbing.
foreach (var theme in _builtIns.Values)
theme.RecomputeAbgrCache();
_active = _builtIns[DefaultSlug];
_customThemesDir = customThemesDir;
}
public Theme Active => _active;
// Read-only exposure of the configured custom themes directory.
// M6 ThemeImportExportRow opens this path via Process.Start.
public string? CustomThemesDir => _customThemesDir;
// Read-only enumeration of all built-in theme slugs. T2 ThemePickerCategoryStep
// diffs this set against ThemePicker.CategoryMapSlugs to enforce coverage.
public IEnumerable<string> BuiltinSlugs => _builtIns.Keys;
// True try-pattern lookup: returns false when neither built-in nor custom
// cache holds the slug, no fallback to default. M3 ThemePicker uses this
// for card-rendering, M6 ThemeImportExportRow for fork-slug collisions.
// Cold-cache fallback: see `LoadCustomBySlug` lookup-by-slug reverse
// iteration — it only walks the pre-populated _customCache. If a freshly
// imported file has not been enumerated yet (or no warm-up ran), the first
// lookup would miss silently. Drain RefreshCustomCache once on miss so the
// custom file gets picked up before the second lookup.
public bool TryGet(string slug, out Theme theme)
{
if (_builtIns.TryGetValue(slug, out var b))
{
theme = b;
return true;
}
var custom = LoadCustomBySlug(slug, out _);
if (custom is null)
{
// Force-enumerate the yield-iterator so _customCache picks up any
// file that landed in the themes dir since the last warm-up.
foreach (var _ in RefreshCustomCache()) { }
custom = LoadCustomBySlug(slug, out _);
}
if (custom is not null)
{
theme = custom;
return true;
}
theme = null!;
return false;
}
public Theme Get(string slug)
{
if (_builtIns.TryGetValue(slug, out var b))
return b;
// Discard the source path here; Switch is the only call-site that
// needs to remember it for the auto-refresh hook.
var custom = LoadCustomBySlug(slug, out _);
if (custom != null)
return custom;
return _builtIns[DefaultSlug];
}
public IEnumerable<Theme> AllBuiltIns() => _builtIns.Values;
public IEnumerable<Theme> AllCustom() => RefreshCustomCache();
// Built-in-first to match Get(slug)'s lookup order. A user theme JSON
// that declares the same slug as a built-in is ignored deliberately --
// having Switch prefer custom and Get prefer built-in would produce
// a state where _active and Get(_active.Slug) disagree.
public void Switch(string slug)
{
// Same-slug switch is a no-op -- avoids a 300ms identity-crossfade
// when the user re-selects the active theme in the picker.
if (string.Equals(_active.Slug, slug, StringComparison.OrdinalIgnoreCase))
return;
if (_editingThemeBuffer is not null)
{
DiscardEditingBuffer();
_logger?.LogWarning("Theme switch to {Slug} discarded unsaved edits", slug);
}
ArmCrossfade();
if (_builtIns.TryGetValue(slug, out var builtin))
{
_active = builtin;
_active.RecomputeAbgrCache();
_activeCustomPath = null;
}
else
{
var customTheme = LoadCustomBySlug(slug, out var customPath);
if (customTheme is not null)
{
_active = customTheme;
// Defensive — ensures any future theme source always gets a populated cache.
_active.RecomputeAbgrCache();
_activeCustomPath = customPath;
// Force a first-tick reload-check after the switch so the stamp
// baseline is established on the next RefreshActiveIfStale call.
_lastActiveStamp = DateTime.MinValue;
}
else
{
// Fallback: neither built-in nor custom matched. Drop to default
// and clear the active custom path so RefreshActiveIfStale stays idle.
_active = _builtIns[DefaultSlug];
_active.RecomputeAbgrCache();
_activeCustomPath = null;
}
}
// Notify listeners (the init host wires the font-atlas rebuild here).
_onActiveChanged?.Invoke();
}
// SwitchSilent is the plugin-load init path -- identical to Switch
// but does NOT arm the crossfade state. Called from
// ThemeRegistryInitHostedService.StartAsync so opening the plugin
// does not produce a 300ms fade from the default theme to the user's
// saved theme.
public void SwitchSilent(string slug)
{
if (string.Equals(_active.Slug, slug, StringComparison.OrdinalIgnoreCase))
return;
if (_editingThemeBuffer is not null)
{
DiscardEditingBuffer();
}
if (_builtIns.TryGetValue(slug, out var builtin))
{
_active = builtin;
_active.RecomputeAbgrCache();
_activeCustomPath = null;
return;
}
var customTheme = LoadCustomBySlug(slug, out var customPath);
if (customTheme is not null)
{
_active = customTheme;
_active.RecomputeAbgrCache();
_activeCustomPath = customPath;
_lastActiveStamp = DateTime.MinValue;
return;
}
_active = _builtIns[DefaultSlug];
_active.RecomputeAbgrCache();
_activeCustomPath = null;
}
public void BeginEditing(Theme source)
{
// Shallow record-with-clone: Theme.Colors gets an explicit second-level
// with-copy so ColorPicker edits never mutate the source record. Layout
// and Typography are value-record-clean (only primitive fields). Chat-
// Colors stays a reference share because the editor never touches
// ChatColors. If a future cycle adds a ChatColors editor,
// BeginEditing must also clone the channel dictionary
// (ThemeChatColors holds IReadOnlyDictionary<ChatType, uint>).
_editingThemeBuffer = source with
{
Colors = source.Colors with { },
};
}
public void UpdateEditingBuffer(ThemeColors newColors)
{
if (_editingThemeBuffer is null)
{
return;
}
_editingThemeBuffer = _editingThemeBuffer with { Colors = newColors };
OnEditingBufferChanged?.Invoke();
}
// CALLER CONTRACT: the buffer slug must NOT collide with a built-in slug.
// Switch() prefers built-ins over custom themes with the same slug
// (see `Switch` built-in-first lookup), so saving a custom file under
// a built-in slug persists the file but leaves the built-in active —
// looks green, behaves broken. M4 ColorPicker DrawIdleState forks
// built-in themes into a custom slug before BeginEditing, M6
// ImportFromPath renames built-in-colliding imports to <slug>_imported.
// New call-sites must either fork first or rename to a non-built-in slug.
public bool SaveEditingBuffer(out string targetPath)
{
targetPath = string.Empty;
if (_editingThemeBuffer is null || _customThemesDir is null)
{
return false;
}
// Slug ends up as a filename below — refuse anything that contains path
// separators, parent-directory tokens, or platform-invalid filename chars.
// Without this guard an imported theme with Slug "../../../etc/passwd"
// would let Path.Combine escape _customThemesDir entirely. Shared helper
// so M6 ImportFromPath uses the exact same rule set.
var safeSlug = _editingThemeBuffer.Slug;
if (!IsSafeThemeSlug(safeSlug))
{
_logger?.LogWarning(
"Refusing to save editing buffer with unsafe slug {Slug}",
safeSlug
);
return false;
}
// Safe-by-construction: refuse any slug that collides with a built-in
// BEFORE we touch the disk. Switch() prefers built-ins over custom files
// with the same slug (see `Switch` built-in-first lookup). Without this
// reject a mis-routed caller (or a future bug in ImportFromPath) could
// persist a custom file under a built-in slug — the file lands on disk,
// Switch keeps the built-in active, and the post-save active-slug check
// below returns false. The caller then sees "save failed" while a garbage
// file accumulates in the themes dir on every retry. M4 ColorPicker forks
// built-in themes into a custom slug before BeginEditing, M6 ImportFromPath
// renames built-in-colliding imports to <slug>_imported, so production
// paths already steer clear; this guard catches everything else.
if (_builtIns.ContainsKey(safeSlug))
{
_logger?.LogWarning(
"Refusing to save editing buffer under built-in slug {Slug}",
safeSlug
);
return false;
}
try
{
targetPath = Path.Combine(_customThemesDir, $"{safeSlug}.json");
// Defence in depth: even after the character-level scrub above, make
// sure the resolved full path is still rooted in _customThemesDir.
// Catches edge cases like alternate data streams or symlink-style
// tricks the loader could otherwise follow.
var fullDir = Path.GetFullPath(_customThemesDir);
var fullTarget = Path.GetFullPath(targetPath);
if (
!fullTarget.StartsWith(
fullDir + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase
)
)
{
_logger?.LogWarning(
"Theme save target {Target} escapes themes dir {Dir}",
fullTarget,
fullDir
);
return false;
}
var json = ThemeJsonWriter.Serialize(_editingThemeBuffer);
// Atomic-replace: write to a sibling .tmp file first, then File.Move
// with overwrite=true. POSIX rename() and Windows MoveFileEx with
// MOVEFILE_REPLACE_EXISTING are both atomic on the same volume — a
// mid-write crash (power loss, Wine kill, OOM) leaves either the
// previous content or the new content on disk, never a partial JSON
// that would silently disappear at next Plugin-Start through the
// ThemeJsonLoader catch-and-continue path inside RefreshCustomCache.
var tmpPath = targetPath + ".tmp";
File.WriteAllText(tmpPath, json);
try
{
File.Move(tmpPath, targetPath, overwrite: true);
}
catch
{
// Avoid `.tmp` litter when Move fails (target locked by AV
// scanner, EXDEV cross-device, share-violation). Best-effort
// delete, then rethrow so the outer IOException catch still
// reports the failure.
try
{
File.Delete(tmpPath);
}
catch
{
// best-effort cleanup
}
throw;
}
// Note: the redundant `_lastActiveStamp = DateTime.MinValue` reset from
// the earlier plan-draft was removed — Switch() itself already resets
// _lastActiveStamp on the custom-theme path (see `Switch`
// custom-theme branch resets `_lastActiveStamp`) as part of the
// active-switch, so a pre-Switch reset is overwritten anyway.
// `RefreshCustomCache` is a yield-iterator (see its `yield return`
// body) — a bare call would build the iterator but never enumerate
// it, so the cache side-effect (_customCache[key] = (theme, stamp))
// would never run. Force-enumerate so the subsequent Switch() finds
// the freshly saved file.
foreach (var _ in RefreshCustomCache()) { }
// Use the sanitised slug for Switch() too — the buffer's raw Slug
// already passed the guard, but staying on safeSlug keeps the lookup
// value consistent with the on-disk filename we just wrote.
var targetSlug = safeSlug;
// CRITICAL: null the buffer BEFORE Switch() so the Switch-Guard
// (step 3d) does not fire on our own save-internal Switch call.
// Without this pre-nullify the guard would log a misleading
// "discarded unsaved edits" warning on every save and run
// DiscardEditingBuffer twice (once in the guard, once at method end).
_editingThemeBuffer = null;
Switch(targetSlug);
// Same-slug in-place edit: Switch() hits its same-slug noop
// early-return (see `Switch` same-slug noop early-return) and
// leaves _active pointing at the PRE-edit Theme reference. The
// newly saved colours would only surface on the next
// RefreshActiveIfStale tick (1Hz-throttled, up to ~1s lag).
// Force-pull the freshly-cached Theme directly so the post-Save
// UI sees the edit in the next frame.
if (string.Equals(_active.Slug, targetSlug, StringComparison.OrdinalIgnoreCase))
{
var reloaded = LoadCustomBySlug(targetSlug, out _);
if (reloaded is not null)
{
reloaded.RecomputeAbgrCache();
_active = reloaded;
// Same-slug save bypasses Switch's notify (it noop'd on same slug);
// fire here so a typography change applies (no-op if size unchanged).
_onActiveChanged?.Invoke();
}
}
// Switch() falls back to DefaultSlug when neither built-in nor custom
// matches (see `Switch` default-slug fallback at the end of the
// method). Verify we actually landed on the intended theme before
// reporting success — a silent fallback to the default would
// otherwise mask a save that did persist the file but failed to
// become active (e.g. cache race on slow disks).
if (!string.Equals(_active.Slug, targetSlug, StringComparison.OrdinalIgnoreCase))
{
// Log filename-only (not the full path) here — the path includes
// the user's home directory which counts as PII. Forensics-critical
// log calls above (path-escape detection) keep the full paths
// because diagnosing the escape needs the resolved target. Memory
// anchor: feedback_hellion_chat_changelog (v1.8.0 PII re-audit
// roadmap).
_logger?.LogWarning(
"SaveEditingBuffer persisted {File} but Switch landed on {Active} instead of {Target}",
Path.GetFileName(targetPath),
_active.Slug,
targetSlug
);
return false;
}
return true;
}
catch (IOException ex)
{
_logger?.LogWarning(
ex,
"I/O error saving editing buffer to {File}",
Path.GetFileName(targetPath)
);
return false;
}
catch (UnauthorizedAccessException ex)
{
_logger?.LogWarning(
ex,
"Access denied saving editing buffer to {File}",
Path.GetFileName(targetPath)
);
return false;
}
catch (JsonException ex)
{
// ThemeJsonWriter.Serialize could in principle throw on malformed
// theme graphs; keep this granular so transient I/O and serialisation
// failures don't get lumped together with future structural bugs.
// Requires `using System.Text.Json;` at the top of ThemeRegistry.cs
// — verify before saving and add the import if it's not yet present.
_logger?.LogWarning(
ex,
"JSON serialisation failed for editing buffer at {File}",
Path.GetFileName(targetPath)
);
return false;
}
}
public void DiscardEditingBuffer()
{
_editingThemeBuffer = null;
}
// Captures the AbgrCache snapshot that PushGlobal should fade FROM.
// If a crossfade is already mid-flight (second Switch within 300ms),
// the current lerped state replaces the snapshot -- the next fade
// starts from where we currently are, not from the original "from".
private void ArmCrossfade()
{
var now = Environment.TickCount64;
ThemeAbgrCache snapshot;
if (
_previousAbgrSnapshot.HasValue
&& _crossfadeStartTickMs != long.MinValue
&& now - _crossfadeStartTickMs < CrossfadeDurationMs
)
{
var t = (float)(now - _crossfadeStartTickMs) / CrossfadeDurationMs;
// A2: SmoothStep easing so the fade eases in/out instead of a
// linear ramp. MUST stay in lockstep with TryGetActiveCrossfade (K8).
var te = t * t * (3f - 2f * t);
snapshot = ThemeAbgrCacheLerp.Lerp(_previousAbgrSnapshot.Value, _active.AbgrCache, te);
}
else
{
snapshot = _active.AbgrCache;
}
_previousAbgrSnapshot = snapshot;
_crossfadeStartTickMs = now;
}
// Returns the lerped AbgrCache while the crossfade is active.
// PushGlobal reads this once per frame; outside the 300ms window
// it short-circuits via the TickCount64 delta so the per-frame
// overhead is a couple of integer comparisons.
public bool TryGetActiveCrossfade(out ThemeAbgrCache lerped)
{
lerped = default;
if (_crossfadeStartTickMs == long.MinValue || !_previousAbgrSnapshot.HasValue)
return false;
var elapsed = Environment.TickCount64 - _crossfadeStartTickMs;
if (elapsed >= CrossfadeDurationMs)
return false;
var t = (float)elapsed / CrossfadeDurationMs;
// A2: SmoothStep easing -- keep identical to ArmCrossfade (K8).
var te = t * t * (3f - 2f * t);
lerped = ThemeAbgrCacheLerp.Lerp(_previousAbgrSnapshot.Value, _active.AbgrCache, te);
return true;
}
// 1Hz-throttled disk-stat on the currently active custom theme file.
// When the file's LastWriteTime moves forward (editor save), reload the
// theme via Get() so the user sees the edit immediately without
// re-selecting in the picker. Built-in themes short-circuit; custom
// themes without an _activeCustomPath (e.g. Switch fell to default)
// short-circuit too.
public void RefreshActiveIfStale()
{
var now = Environment.TickCount64;
if (now - _lastActiveStampCheckMs < ActiveStampPollIntervalMs)
return;
_lastActiveStampCheckMs = now;
if (_active.IsBuiltIn)
return;
var path = _activeCustomPath;
if (path is null || !File.Exists(path))
return;
var stamp = File.GetLastWriteTimeUtc(path);
if (!ThemeStampDiff.IsStale(_lastActiveStamp, stamp))
return;
_lastActiveStamp = stamp;
// Get() re-runs RefreshCustomCache which picks up the new content
// (the cache keys by path + LastWriteTime, so a mtime bump invalidates).
// RecomputeAbgrCache happens inside RefreshCustomCache on cache miss.
var reloaded = Get(_active.Slug);
_active = reloaded;
_onActiveChanged?.Invoke();
}
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION.
// Other IO failures are permanent — theme is dropped instead of retried.
internal static bool IsRecoverableFileLock(Exception? ex)
{
if (ex is not IOException io)
return false;
var code = (uint)io.HResult;
return code == 0x80070020u || code == 0x80070021u;
}
// Slug -> Theme lookup with the source path as an out-param so the
// Switch path can remember which file backs the active custom theme.
// Pure reverse-lookup over the existing _customCache: that cache is
// already Path -> (Theme, Stamp), so iterating it costs nothing,
// avoids a re-parse of every JSON, and keeps the parse logic (and
// the recoverable-file-lock recovery) confined to RefreshCustomCache.
// The cache must be warm before this runs; Plugin.LoadAsync triggers
// a one-time warm-up via AllCustom() before the first Switch call.
private Theme? LoadCustomBySlug(string slug, out string? sourcePath)
{
sourcePath = null;
if (_customThemesDir is null)
return null;
if (!Directory.Exists(_customThemesDir))
return null;
foreach (var kvp in _customCache)
{
if (string.Equals(kvp.Value.Theme.Slug, slug, StringComparison.OrdinalIgnoreCase))
{
sourcePath = kvp.Key;
return kvp.Value.Theme;
}
}
return null;
}
private IEnumerable<Theme> RefreshCustomCache()
{
if (_customThemesDir is null || !Directory.Exists(_customThemesDir))
yield break;
var seenSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in Directory.EnumerateFiles(_customThemesDir, "*.json"))
{
Theme? theme = null;
var stamp = File.GetLastWriteTimeUtc(path);
var key = path;
if (_customCache.TryGetValue(key, out var cached) && cached.Stamp == stamp)
{
theme = cached.Theme;
}
else
{
try
{
theme = ThemeJsonLoader.LoadFromFile(path, _logger);
// null = hard-cut policy skipped a legacy v1 file. Leave
// theme null so the yield-guard below drops the entry.
if (theme is not null)
{
theme.RecomputeAbgrCache();
_customCache[key] = (theme, stamp);
}
}
catch (Exception ex) when (IsRecoverableFileLock(ex))
{
// Editor mid-save: keep last known good, retry on next refresh.
_logger?.LogDebug(
$"Custom theme {Path.GetFileName(path)} is locked, keeping last known good"
);
if (cached.Theme is not null)
theme = cached.Theme;
}
catch (Exception)
{
continue;
}
}
if (theme is not null && seenSlugs.Add(theme.Slug))
yield return theme;
}
}
}