diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index c603ad0..f1445c9 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -158,6 +158,16 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService() )); + services.AddSingleton(sp => new Ui.Components.Settings.ThemeImportExportRow( + sp.GetRequiredService(), + sp.GetRequiredService>() + )); + services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AppearanceTab( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(sp => new Ui.Components.StatusBar( sp.GetRequiredService(), sp.GetRequiredService() diff --git a/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs b/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs new file mode 100644 index 0000000..1782df3 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs @@ -0,0 +1,47 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Ui.Components.Settings; + +namespace HellionChat.Ui.Components.Settings.Tabs; + +internal sealed class AppearanceTab +{ + private readonly ThemePicker _picker; + private readonly ColorPicker _color; + private readonly LivePreviewPanel _preview; + private readonly ThemeImportExportRow _importExport; + + public AppearanceTab( + ThemePicker picker, + ColorPicker color, + LivePreviewPanel preview, + ThemeImportExportRow importExport + ) + { + _picker = picker; + _color = color; + _preview = preview; + _importExport = importExport; + } + + public void Draw() + { + var availableX = ImGui.GetContentRegionAvail().X; + var leftWidth = MathF.Max(0, availableX - 290); + + using (var left = ImRaii.Child("##appearance-left", new Vector2(leftWidth, 0))) + { + if (left.Success) + { + _picker.Draw(); + ImGui.Spacing(); + _importExport.Draw(); + ImGui.Separator(); + _color.Draw(); + } + } + ImGui.SameLine(); + _preview.Draw(); + } +} diff --git a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs new file mode 100644 index 0000000..f76db1d --- /dev/null +++ b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs @@ -0,0 +1,296 @@ +using System.Diagnostics; +using System.Security; +using Dalamud.Bindings.ImGui; +using HellionChat.Themes; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Ui.Components.Settings; + +internal sealed class ThemeImportExportRow +{ + private readonly ThemeRegistry _themes; + private readonly ILogger _logger; + private string _importPath = string.Empty; + + public ThemeImportExportRow(ThemeRegistry themes, ILogger logger) + { + _themes = themes; + _logger = logger; + } + + public void Draw() + { + if (ImGui.Button("Fork active theme")) + { + ForkActive(); + } + + ImGui.SameLine(); + if (ImGui.Button("Import theme file…")) + { + ImportFromPath(_importPath); + } + + ImGui.SameLine(); + if (ImGui.Button("Open themes folder")) + { + OpenThemesFolder(); + } + + ImGui.SetNextItemWidth(-1); + ImGui.InputTextWithHint( + "##theme-import-path", + "Path to JSON file (or drag-and-drop into the folder)", + ref _importPath, + 512 + ); + } + + private void ForkActive() + { + var source = _themes.Active; + var suffix = source.IsBuiltIn ? "fork" : "copy"; + var newSlug = $"{source.Slug}_{suffix}"; + var attempt = 2; + // Bounds the slug-collision search so a buggy TryGet (or a degenerate + // themes directory with 100+ collisions on the same prefix) cannot + // spin the UI thread indefinitely. 100 is the bound for a sensible + // user state — anything past that means the themes folder is broken, + // surfaces as a log warning instead of a frozen frame. + const int MaxAttempts = 100; + while (_themes.TryGet(newSlug, out _)) + { + if (attempt > MaxAttempts) + { + _logger.LogWarning( + "ForkActive aborted after {Max} slug-collision attempts on prefix {Prefix}", + MaxAttempts, + $"{source.Slug}_{suffix}" + ); + return; + } + newSlug = $"{source.Slug}_{suffix}_{attempt++}"; + } + + var forked = source with + { + Slug = newSlug, + Name = $"{source.Name} ({suffix})", + IsBuiltIn = false, + }; + _themes.BeginEditing(forked); + if (!_themes.SaveEditingBuffer(out var forkedPath)) + { + _logger.LogWarning( + "Fork-active save failed for slug {Slug}; editing buffer left untouched", + newSlug + ); + } + else + { + _logger.LogInformation("Forked active theme to {Path}", forkedPath); + } + } + + // 64 KiB cap so a typo or accidental 500MB-file drop does not pull + // arbitrary bytes into memory before the loader rejects it. HellionArctic + // serialises to ~3 KiB so 64 KiB is generous for legitimate themes. + private const int MaxImportFileBytes = 64 * 1024; + + private void ImportFromPath(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + _logger.LogWarning("Import skipped: file not found at {Path}", path); + return; + } + + // Extension guard — refuse non-.json before reading any bytes. + // Cost of a typo (or ~/.ssh/id_rsa dropped into the box) is bounded + // before file I/O happens. + if (!Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Import skipped: not a .json file at {Path}", path); + return; + } + + // Size guard before ReadAllText so we never pull arbitrary bytes + // into memory or into logger exception messages. + long size; + try + { + size = new FileInfo(path).Length; + } + catch (Exception ex) + when (ex is IOException or UnauthorizedAccessException or SecurityException) + { + _logger.LogWarning(ex, "Import skipped: cannot stat {Path}", path); + return; + } + if (size > MaxImportFileBytes) + { + _logger.LogWarning( + "Import skipped: file {Path} is {Size} bytes, exceeds {Max}", + path, + size, + MaxImportFileBytes + ); + return; + } + + try + { + var json = File.ReadAllText(path); + Theme? theme; + try + { + theme = ThemeJsonLoader.LoadFromString(json); + } + catch (FormatException) + { + // Swallow the FormatException body deliberately — the loader's + // message can include slices of the input (e.g. unterminated + // string contents). For non-JSON files chosen by mistake that + // could leak file content into the log. Path alone is enough + // to diagnose. + _logger.LogWarning("Import skipped: malformed theme JSON at {Path}", path); + return; + } + + if (theme is null) + { + _logger.LogWarning("Import skipped: invalid theme JSON at {Path}", path); + return; + } + + // Slug sanitisation BEFORE BeginEditing — SaveEditingBuffer would + // reject too, but rejecting here means an unsafe slug never enters + // the editing buffer. Shared helper ThemeRegistry.IsSafeThemeSlug + // keeps the rule set in sync with F1's save-side guard (see + // ThemeRegistry.IsSafeThemeSlug shared helper). + var importSlug = theme.Slug; + if (!ThemeRegistry.IsSafeThemeSlug(importSlug)) + { + _logger.LogWarning( + "Import skipped: theme at {Path} declares unsafe slug {Slug}", + path, + importSlug + ); + return; + } + + // Pragmatic deviation from §1.6 wording ("File.Copy into themes/"): + // BeginEditing+SaveEditingBuffer produces the same end-state and + // reuses the validated F1 save pipeline. Trade-off: destination + // filename becomes the theme's slug, not the original filename. + // + // Slug-collision handling: + // * Built-in collision -> rename to _imported. Switch() + // prefers built-ins (see ThemeRegistry.Switch built-in-first + // lookup), so a same-slug custom theme would persist on disk + // but never become active. + // * Custom-vs-custom collision -> rename to _imported_. + // Silent overwrite is dangerous: if the colliding custom theme + // is active right now, the import would replace the live theme + // with no undo path. Renaming preserves both files; the user + // can delete the imported copy from the themes folder if it + // was truly meant as an overwrite. + var importTheme = theme; + if (_themes.BuiltinSlugs.Contains(importTheme.Slug, StringComparer.OrdinalIgnoreCase)) + { + var renamedSlug = $"{importTheme.Slug}_imported"; + _logger.LogWarning( + "Imported theme slug {Slug} collides with a built-in; renaming to {Renamed}", + importTheme.Slug, + renamedSlug + ); + importTheme = importTheme with { Slug = renamedSlug }; + } + else if ( + _themes.TryGet(importTheme.Slug, out var existingCustom) + && !existingCustom.IsBuiltIn + ) + { + // Bounded slug-collision search (same rationale as ForkActive + // loop above): 100 attempts max so a pathological themes + // folder cannot spin the UI thread. + var baseSlug = $"{importTheme.Slug}_imported"; + var renamedSlug = baseSlug; + var attempt = 2; + const int MaxAttempts = 100; + while (_themes.TryGet(renamedSlug, out _)) + { + if (attempt > MaxAttempts) + { + _logger.LogWarning( + "Import aborted after {Max} custom-slug-collision attempts on prefix {Prefix}", + MaxAttempts, + baseSlug + ); + return; + } + renamedSlug = $"{baseSlug}_{attempt++}"; + } + _logger.LogWarning( + "Imported theme slug {Slug} collides with an existing custom theme; renaming to {Renamed}", + importTheme.Slug, + renamedSlug + ); + importTheme = importTheme with { Slug = renamedSlug }; + } + _themes.BeginEditing(importTheme); + if (!_themes.SaveEditingBuffer(out var importedPath)) + { + _logger.LogWarning( + "Import save failed for slug {Slug} from {Path}", + importTheme.Slug, + path + ); + } + else + { + _logger.LogInformation( + "Imported theme {Slug} from {Path} to {DestPath}", + importTheme.Slug, + path, + importedPath + ); + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "I/O error importing theme from {Path}", path); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Access denied importing theme from {Path}", path); + } + } + + // dir is sourced from ThemeRegistry.CustomThemesDir, built once in the + // registry ctor from a plugin-managed config path — never from user + // input. Process.Start with UseShellExecute=true is safe under that + // constraint. If a future cycle ever feeds user-supplied path here + // (custom-themes-dir override UI, drag-and-drop folder picker), validate + // it stays inside the plugin's config root BEFORE Process.Start + // (Path.GetFullPath comparison analogous to ThemeRegistry.SaveEditingBuffer's + // path-escape guard). Without that, a poisoned config could point at any + // directory on disk. + private void OpenThemesFolder() + { + var dir = _themes.CustomThemesDir; + if (string.IsNullOrEmpty(dir)) + { + return; + } + + try + { + Process.Start(new ProcessStartInfo(dir) { UseShellExecute = true }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not open themes folder {Dir}", dir); + } + } +} diff --git a/HellionChat/Ui/Windows/SettingsWindow.cs b/HellionChat/Ui/Windows/SettingsWindow.cs index fb76c72..55bb744 100644 --- a/HellionChat/Ui/Windows/SettingsWindow.cs +++ b/HellionChat/Ui/Windows/SettingsWindow.cs @@ -4,6 +4,7 @@ using Dalamud.Interface.Windowing; using Dalamud.Utility; using HellionChat.Resources; using HellionChat.Ui.Components.Settings; +using HellionChat.Ui.Components.Settings.Tabs; using Microsoft.Extensions.Logging; namespace HellionChat.Ui.Windows; @@ -18,6 +19,7 @@ internal sealed class SettingsWindow : Window private readonly ThemePicker _themePicker; private readonly ColorPicker _colorPicker; private readonly LivePreviewPanel _livePreview; + private readonly AppearanceTab _appearance; public SettingsWindow( Plugin plugin, @@ -26,6 +28,7 @@ internal sealed class SettingsWindow : Window ThemePicker themePicker, ColorPicker colorPicker, LivePreviewPanel livePreview, + AppearanceTab appearance, ILoggerFactory loggerFactory ) : base($"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings") @@ -36,6 +39,7 @@ internal sealed class SettingsWindow : Window _themePicker = themePicker; _colorPicker = colorPicker; _livePreview = livePreview; + _appearance = appearance; _ = loggerFactory; Size = new Vector2(720, 540); @@ -62,8 +66,14 @@ internal sealed class SettingsWindow : Window private void RenderActiveTab(string tabId) { - // M6-M12 fill these branches; skeleton renders a placeholder per tab so - // the smoke check confirms tab switching works. - ImGui.TextUnformatted($"[{tabId}] tab content lands in later task"); + switch (tabId) + { + case "appearance": + _appearance.Draw(); + break; + default: + ImGui.TextUnformatted($"[{tabId}] tab content lands in later task"); + break; + } } }