using System.Text.Json; using HellionChat.Themes.Builtin; using Microsoft.Extensions.Logging; namespace HellionChat.Themes; public sealed class ThemeRegistry { private readonly ILogger? _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 _builtIns; private readonly Dictionary _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? 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(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 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 AllBuiltIns() => _builtIns.Values; public IEnumerable 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). _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 _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 _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 RefreshCustomCache() { if (_customThemesDir is null || !Directory.Exists(_customThemesDir)) yield break; var seenSlugs = new HashSet(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; } } }