From 83bd88799b2cbcaf0ebdd2343943637c061d1b0a Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:29:50 -0300 Subject: [PATCH 01/11] SMW file setup --- .gitignore | 3 + SubathonManager.Core/Enums/WidgetType.cs | 8 + .../Interfaces/IWidgetFileSystem.cs | 30 + SubathonManager.Core/Models/Widget.cs | 40 +- SubathonManager.Core/Utils.cs | 1 + SubathonManager.Data/OverlayPorter.cs | 22 +- SubathonManager.Data/WidgetEntityHelper.cs | 69 +-- SubathonManager.Data/WidgetPorter.cs | 556 ++++++++++++++++++ .../Widgets/WidgetDisplayHelper.cs | 25 + .../Widgets/WidgetPackFileSystem.cs | 85 +++ .../Widgets/WidgetPackInstaller.cs | 235 ++++++++ .../Widgets/WidgetPackMemoryCache.cs | 100 ++++ .../Widgets/WidgetPackPaths.cs | 191 ++++++ .../Widgets/WidgetPackReader.cs | 163 +++++ .../AspNetContextAdapter.cs | 9 + .../Interfaces/IHttpContext.cs | 5 +- SubathonManager.Server/WebServer.Api.cs | 2 + SubathonManager.Server/WebServer.Overlays.cs | 22 +- SubathonManager.Server/WebServer.WebSocket.cs | 1 + SubathonManager.Server/WebServer.cs | 1 + .../ServerUnitTests/MockHttpContext.cs | 12 +- .../WebServerWebSocketTests.cs | 1 + SubathonManager.UI/App.axaml.cs | 25 + SubathonManager.UI/Converters/Converters.cs | 41 ++ .../EditRouteWindow.Handlers.cs | 336 ++++++++++- SubathonManager.UI/EditRouteWindow.axaml | 37 +- SubathonManager.UI/EditRouteWindow.axaml.cs | 48 +- SubathonManager.UI/MainWindow.axaml.cs | 48 +- .../Platform/IPlatformIntegration.cs | 1 + .../Platform/PosixPlatformIntegration.cs | 10 +- SubathonManager.UI/Platform/ProtocolParser.cs | 6 + .../Platform/WindowsPlatformIntegration.cs | 6 + .../Views/ExportOverlayDialog.axaml.cs | 7 +- .../Views/ExportWidgetDialog.axaml | 99 ++++ .../Views/ExportWidgetDialog.axaml.cs | 347 +++++++++++ external/linux/install.sh | 13 +- external/linux/subathonmanager-smo.xml | 6 + external/macos/Info.plist | 37 +- 38 files changed, 2540 insertions(+), 108 deletions(-) create mode 100644 SubathonManager.Core/Interfaces/IWidgetFileSystem.cs create mode 100644 SubathonManager.Data/WidgetPorter.cs create mode 100644 SubathonManager.Data/Widgets/WidgetDisplayHelper.cs create mode 100644 SubathonManager.Data/Widgets/WidgetPackFileSystem.cs create mode 100644 SubathonManager.Data/Widgets/WidgetPackInstaller.cs create mode 100644 SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs create mode 100644 SubathonManager.Data/Widgets/WidgetPackPaths.cs create mode 100644 SubathonManager.Data/Widgets/WidgetPackReader.cs create mode 100644 SubathonManager.UI/Views/ExportWidgetDialog.axaml create mode 100644 SubathonManager.UI/Views/ExportWidgetDialog.axaml.cs diff --git a/.gitignore b/.gitignore index 03709b50..6a8bb154 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,10 @@ riderModule.iml exports/ imports/ +cache/ *.smo +*.smw +!presets/**/*.smw # If we set this up !.idea/codeStyles/ diff --git a/SubathonManager.Core/Enums/WidgetType.cs b/SubathonManager.Core/Enums/WidgetType.cs index 86807663..5d5d557e 100644 --- a/SubathonManager.Core/Enums/WidgetType.cs +++ b/SubathonManager.Core/Enums/WidgetType.cs @@ -7,6 +7,14 @@ public enum WidgetType Video = 2 } +public enum WidgetDisplayKind +{ + Widget,// + UnpackedWidget,// + Image, + Video +} + public static class WidgetTypeHelper { private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) diff --git a/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs b/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs new file mode 100644 index 00000000..be1e5920 --- /dev/null +++ b/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs @@ -0,0 +1,30 @@ +namespace SubathonManager.Core.Interfaces; + +public interface IWidgetFileSystem +{ + bool Exists(string path); + string? ReadAllText(string path); + byte[]? ReadAllBytes(string path); + bool IsPacked(string path); + string? GetRealFilePath(string path); + IEnumerable EnumerateFiles(string directory); +} + +public sealed class DiskWidgetFileSystem : IWidgetFileSystem +{ + public bool Exists(string path) => File.Exists(path); + public string? ReadAllText(string path) => File.Exists(path) ? File.ReadAllText(path) : null; + public byte[]? ReadAllBytes(string path) => File.Exists(path) ? File.ReadAllBytes(path) : null; + public bool IsPacked(string path) => false; + public string? GetRealFilePath(string path) => File.Exists(path) ? path : null; + + public IEnumerable EnumerateFiles(string directory) => + Directory.Exists(directory) + ? Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) + : []; +} + +public static class WidgetFiles +{ + public static IWidgetFileSystem Current { get; set; } = new DiskWidgetFileSystem(); +} diff --git a/SubathonManager.Core/Models/Widget.cs b/SubathonManager.Core/Models/Widget.cs index dfa7f455..e57819ea 100644 --- a/SubathonManager.Core/Models/Widget.cs +++ b/SubathonManager.Core/Models/Widget.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; @@ -300,36 +301,51 @@ public void ScanCssVariables() CssVariables = ExtractCssVariablesFromFiles(); } - public List ExtractCssVariablesFromFiles() + public List GetLinkedCssPaths() { - var extractedVars = new List(); + var paths = new List(); - if (!File.Exists(HtmlPath)) - return extractedVars; + var html = WidgetFiles.Current.ReadAllText(HtmlPath); + if (html == null) + return paths; - string htmlContent = File.ReadAllText(HtmlPath); string baseDir = Path.GetDirectoryName(HtmlPath)!; - var cssMatches = CssLinkRegex().Matches(htmlContent); - - foreach (Match match in cssMatches) + foreach (Match match in CssLinkMatches(html)) { string cssFile = match.Groups[1].Value; string cssPath = Path.IsPathRooted(cssFile) ? cssFile : Path.Combine(baseDir, cssFile); - if (!File.Exists(cssPath)) + if (!WidgetFiles.Current.Exists(cssPath)) + continue; + if (paths.Contains(cssPath, StringComparer.OrdinalIgnoreCase)) continue; - string cssContent = File.ReadAllText(cssPath); + paths.Add(cssPath); + } + + return paths; + } + + public static MatchCollection CssLinkMatches(string htmlContent) => CssLinkRegex().Matches(htmlContent); + + public List ExtractCssVariablesFromFiles() + { + var extractedVars = new List(); + + foreach (var cssPath in GetLinkedCssPaths()) + { + string? cssContent = WidgetFiles.Current.ReadAllText(cssPath); + if (cssContent == null) continue; var varMatches = CssVarRegex().Matches(cssContent); string metaPath = cssPath + ".json"; Dictionary>? varTypes = null; - if (File.Exists(metaPath)) + var metaJson = WidgetFiles.Current.ReadAllText(metaPath); + if (metaJson != null) { - var metaJson = File.ReadAllText(metaPath); varTypes = JsonSerializer.Deserialize>>(metaJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = false }); } diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index 9e7695db..1759e031 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -18,6 +18,7 @@ public static class Utils private static readonly ConcurrentDictionary<(SubathonEventSource Source, string Service), IntegrationConnection> ConnectionDetails = new(); public static string? PendingOverlayImportPath { get; set; } + public static string? PendingWidgetPackImportPath { get; set; } public static OAuthCallback? PendingOAuthCallback { get; set; } public static IEnumerable GetAllConnections() diff --git a/SubathonManager.Data/OverlayPorter.cs b/SubathonManager.Data/OverlayPorter.cs index d641fa9d..3693ed59 100644 --- a/SubathonManager.Data/OverlayPorter.cs +++ b/SubathonManager.Data/OverlayPorter.cs @@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore; using SubathonManager.Core; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; // ReSharper disable NullableWarningSuppressionIsUsed @@ -37,10 +38,21 @@ public static async Task ExportRouteAsync(Route route, string outputPath, string var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var (srcFile, zipEntry) in plan.FileCopies) { - if (!File.Exists(srcFile)) continue; + if (!WidgetFiles.Current.Exists(srcFile)) continue; if (!seen.Add(zipEntry)) continue; if (excludedZipEntries != null && excludedZipEntries.Contains(zipEntry)) continue; - archive.CreateEntryFromFile(srcFile, zipEntry, CompressionLevel.Optimal); + + if (File.Exists(srcFile)) + { + await archive.CreateEntryFromFileAsync(srcFile, zipEntry, CompressionLevel.Optimal); + continue; + } + + var bytes = WidgetFiles.Current.ReadAllBytes(srcFile); + if (bytes == null) continue; + var packedEntry = archive.CreateEntry(zipEntry, CompressionLevel.Optimal); + await using var packedStream = packedEntry.Open(); + await packedStream.WriteAsync(bytes); } var manifest = BuildManifest(route, widgets, plan, exportName, version, appVersion); @@ -88,15 +100,15 @@ private static ExportPlan BuildExportPlan(List widgets) if (widget.Type.IsAsset()) { - if (File.Exists(widget.HtmlPath)) + if (WidgetFiles.Current.Exists(widget.HtmlPath)) { string fileName = Path.GetFileName(widget.HtmlPath); plan.FileCopies.Add((widget.HtmlPath, $"{zipWidgetRoot}/{fileName}")); } } - else if (Directory.Exists(widgetRoot)) + else { - foreach (var file in Directory.EnumerateFiles(widgetRoot, "*", SearchOption.AllDirectories)) + foreach (var file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) { string relative = Path.GetRelativePath(widgetRoot, file).Replace('\\', '/'); plan.FileCopies.Add((file, $"{zipWidgetRoot}/{relative}")); diff --git a/SubathonManager.Data/WidgetEntityHelper.cs b/SubathonManager.Data/WidgetEntityHelper.cs index 9d2f8040..72f0fd82 100644 --- a/SubathonManager.Data/WidgetEntityHelper.cs +++ b/SubathonManager.Data/WidgetEntityHelper.cs @@ -1,5 +1,4 @@ -using System.Reflection; -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -7,6 +6,7 @@ using System.Text.RegularExpressions; using SubathonManager.Core.Events; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Core; using SubathonManager.Core.Objects; @@ -250,26 +250,14 @@ public void SyncJsVariables(Widget widget) public WidgetMeta ExtractWidgetMetadataSync(string htmlpath) { var jsonPath = htmlpath + ".json"; - if (!File.Exists(jsonPath)) - { - var html = File.ReadAllText(htmlpath); - Dictionary data = GetMetaDataHtml(html); - try - { - File.WriteAllText(jsonPath, JsonSerializer.Serialize(ConvertHtmlMetaToJsonMeta(data), JsonOptions)); - _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); - return new WidgetMeta(); - } - - } + if (!EnsureMetaSidecar(htmlpath, jsonPath, out var packedFallback)) + return packedFallback; + try { - var json = File.ReadAllText(jsonPath); - return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); + var json = WidgetFiles.Current.ReadAllText(jsonPath); + if (json != null) + return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); } catch (Exception ex) { @@ -279,36 +267,31 @@ public WidgetMeta ExtractWidgetMetadataSync(string htmlpath) return new WidgetMeta(); } - public async Task ExtractWidgetMetadata(string htmlpath) + public Task ExtractWidgetMetadata(string htmlpath) + => Task.FromResult(ExtractWidgetMetadataSync(htmlpath)); + + private bool EnsureMetaSidecar(string htmlpath, string jsonPath, out WidgetMeta packedFallback) { - var jsonPath = htmlpath + ".json"; - if (!File.Exists(jsonPath)) - { - var html = await File.ReadAllTextAsync(htmlpath); - Dictionary data = GetMetaDataHtml(html); - try - { - await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(ConvertHtmlMetaToJsonMeta(data), JsonOptions)); - _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); - return new WidgetMeta(); - } - } - + packedFallback = new WidgetMeta(); + + if (WidgetFiles.Current.Exists(jsonPath)) return true; + if (WidgetFiles.Current.IsPacked(htmlpath)) return false; + + var html = WidgetFiles.Current.ReadAllText(htmlpath); + if (html == null) return false; + try { - var json = await File.ReadAllTextAsync(jsonPath); - return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); + var meta = ConvertHtmlMetaToJsonMeta(GetMetaDataHtml(html)); + File.WriteAllText(jsonPath, JsonSerializer.Serialize(meta, JsonOptions)); + _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); + return true; } catch (Exception ex) { - _logger?.LogWarning(ex, "Failed to read widget meta JSON at {Path}", jsonPath); + _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); + return false; } - - return new WidgetMeta(); } internal WidgetMeta ConvertHtmlMetaToJsonMeta(Dictionary data) diff --git a/SubathonManager.Data/WidgetPorter.cs b/SubathonManager.Data/WidgetPorter.cs new file mode 100644 index 00000000..da1e474e --- /dev/null +++ b/SubathonManager.Data/WidgetPorter.cs @@ -0,0 +1,556 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Data.Widgets; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data; + +public static partial class WidgetPorter +{ + public const string ManifestFileName = WidgetPackInstaller.ManifestFileName; + public const string ContentFolder = "content"; + public const string ExternalFolder = "_external"; + public const string SharedFolder = "_shared"; + public const string FormatVersion = "1"; + + private static readonly JsonSerializerOptions MetaOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new JsonStringEnumConverter() } + }; + + private static readonly JsonSerializerOptions ManifestOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new JsonStringEnumConverter() } + }; + + #region TYPES + + public enum SmwEntryKind + { + Manifest, + Entry, + Css, + Meta, + Asset, + External + } + + public class SmwEntry + { + public required string ZipEntry { get; init; } + public string? AbsSource { get; init; } + public SmwEntryKind Kind { get; init; } + public bool DefaultSelected { get; set; } + public bool Locked { get; init; } + public Func? Generator { get; init; } + } + + public class SmwExportOptions + { + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + public string Group { get; init; } = string.Empty; + + public string Version { get; init; } = "1.0.0"; + public string AppVersion { get; init; } = string.Empty; + + public List Tags { get; init; } = []; + } + + public static List ParseTags(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return []; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + return raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Where(tag => seen.Add(tag)) + .ToList(); + } + + public class ExportPlan + { + public List Entries { get; } = []; + public Dictionary VariableRewrites { get; } = new(StringComparer.OrdinalIgnoreCase); + public string EntryZipPath { get; set; } = string.Empty; + } + + #endregion + + #region PLAN + + public static ExportPlan BuildPlan(Widget widget) + { + var plan = new ExportPlan(); + if (widget.Type.IsAsset() || !WidgetFiles.Current.Exists(widget.HtmlPath)) return plan; + + string widgetRoot = widget.GetPath(); + string htmlEntry = $"{ContentFolder}/{Path.GetFileName(widget.HtmlPath)}"; + plan.EntryZipPath = htmlEntry; + + var cssPaths = widget.GetLinkedCssPaths(); + var cssZipMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + var htmlLinkRewrites = new Dictionary(StringComparer.Ordinal); + + foreach (var cssPath in cssPaths) + { + string? entry = ToContentEntry(widgetRoot, cssPath); + if (entry == null) + { + entry = $"{ContentFolder}/{SharedFolder}/{Path.GetFileName(cssPath)}"; + string original = GetHtmlLinkFor(widget, cssPath); + if (!string.IsNullOrEmpty(original)) + htmlLinkRewrites[original] = $"./{SharedFolder}/{Path.GetFileName(cssPath)}"; + } + cssZipMap[cssPath] = entry; + } + + plan.Entries.Add(new SmwEntry + { + ZipEntry = ManifestFileName, + Kind = SmwEntryKind.Manifest, + DefaultSelected = true, + Locked = true, + Generator = opts => Encoding.UTF8.GetBytes(BuildManifest(widget, plan, opts)) + }); + + Func? htmlGenerator = null; + if (htmlLinkRewrites.Count > 0) + htmlGenerator = _ => Encoding.UTF8.GetBytes( + RewriteHtmlLinks(WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty, htmlLinkRewrites)); + + plan.Entries.Add(new SmwEntry + { + ZipEntry = htmlEntry, + AbsSource = widget.HtmlPath, + Kind = SmwEntryKind.Entry, + DefaultSelected = true, + Locked = true, + Generator = htmlGenerator + }); + + plan.Entries.Add(new SmwEntry + { + ZipEntry = $"{htmlEntry}.json", + AbsSource = WidgetFiles.Current.Exists(widget.HtmlPath + ".json") ? widget.HtmlPath + ".json" : null, + Kind = SmwEntryKind.Meta, + DefaultSelected = true, + Generator = opts => Encoding.UTF8.GetBytes(BuildWidgetMetaJson(widget, plan, opts)) + }); + + var consumed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var cssPath in cssPaths) + { + string cssEntry = cssZipMap[cssPath]; + var varsInFile = ClaimCssVariables(widget, cssPath, consumed); + + Func? cssGenerator = null; + if (varsInFile.Count > 0) + { + string capturedPath = cssPath; + var capturedVars = varsInFile; + cssGenerator = _ => Encoding.UTF8.GetBytes( + OverrideCssValues(WidgetFiles.Current.ReadAllText(capturedPath) ?? string.Empty, capturedVars)); + } + + plan.Entries.Add(new SmwEntry + { + ZipEntry = cssEntry, + AbsSource = cssPath, + Kind = SmwEntryKind.Css, + DefaultSelected = true, + Generator = cssGenerator + }); + + string cssMetaPath = cssPath + ".json"; + bool hasMeta = WidgetFiles.Current.Exists(cssMetaPath); + bool worthWriting = varsInFile.Any(v => + v.Type != WidgetCssVariableType.Default || !string.IsNullOrWhiteSpace(v.Description)); + + if (!hasMeta && !worthWriting) continue; + + string capturedMetaPath = cssMetaPath; + var capturedMetaVars = varsInFile; + plan.Entries.Add(new SmwEntry + { + ZipEntry = $"{cssEntry}.json", + AbsSource = hasMeta ? cssMetaPath : null, + Kind = SmwEntryKind.Meta, + DefaultSelected = true, + Generator = _ => Encoding.UTF8.GetBytes(BuildCssMetaJson(capturedMetaPath, capturedMetaVars)) + }); + } + + var claimed = new HashSet(plan.Entries.Select(en => en.ZipEntry), + StringComparer.OrdinalIgnoreCase); + foreach (var file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) + { + string? entry = ToContentEntry(widgetRoot, file); + if (entry == null || !claimed.Add(entry)) continue; + + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = file, + Kind = SmwEntryKind.Asset, + DefaultSelected = false + }); + } + + AddFileVariableEntries(widget, widgetRoot, plan, claimed); + return plan; + } + + private static void AddFileVariableEntries(Widget widget, string widgetRoot, ExportPlan plan, HashSet claimed) + { + foreach (var jsVar in widget.JsVariables) + { + if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; + if (string.IsNullOrWhiteSpace(jsVar.Value)) continue; + + bool isFolderType = jsVar.Type == WidgetVariableType.FolderPath; + bool isRelative = jsVar.Value.StartsWith("./") || jsVar.Value.StartsWith("../"); + + if (isRelative) + { + string rel = jsVar.Value.TrimStart('.').TrimStart('/'); + string prefix = $"{ContentFolder}/{rel}"; + foreach (var entry in plan.Entries.Where(en => + en.Kind == SmwEntryKind.Asset && + (en.ZipEntry.Equals(prefix, StringComparison.OrdinalIgnoreCase) || + en.ZipEntry.StartsWith(prefix.TrimEnd('/') + "/", + StringComparison.OrdinalIgnoreCase)))) + { + entry.DefaultSelected = true; + } + continue; + } + + if (!Path.IsPathRooted(jsVar.Value)) continue; + + if (isFolderType && Directory.Exists(jsVar.Value)) + { + string varFolder = SanitizeName(jsVar.Name); + foreach (var file in Directory.EnumerateFiles(jsVar.Value, "*", + SearchOption.AllDirectories)) + { + string rel = Path.GetRelativePath(jsVar.Value, file).Replace('\\', '/'); + string entry = $"{ContentFolder}/{ExternalFolder}/{varFolder}/{rel}"; + + if (!claimed.Add(entry)) continue; + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = file, + Kind = SmwEntryKind.External, + DefaultSelected = true + }); + } + + plan.VariableRewrites[jsVar.Name] = $"./{ExternalFolder}/{varFolder}"; + } + else if (!isFolderType && File.Exists(jsVar.Value)) + { + string fileName = Path.GetFileName(jsVar.Value); + string entry = $"{ContentFolder}/{ExternalFolder}/{fileName}"; + if (claimed.Add(entry)) + { + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = jsVar.Value, + Kind = SmwEntryKind.External, + DefaultSelected = true + }); + } + plan.VariableRewrites[jsVar.Name] = $"./{ExternalFolder}/{fileName}"; + } + } + } + + #endregion + + #region EXPORT + + public static async Task ExportWidgetAsync(ExportPlan plan, SmwExportOptions options, string outputPath) + { + var opts = options; + if (string.IsNullOrWhiteSpace(opts.AppVersion)) + { + opts = new SmwExportOptions + { + Name = options.Name, + Author = options.Author, + Group = options.Group, + Version = options.Version, + Tags = options.Tags, + AppVersion = AppServices.AppVersion + }; + } + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath))!); + + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); + await using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false); + + var written = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in plan.Entries) + { + if (entry is { Locked: false, DefaultSelected: false }) continue; + if (!written.Add(entry.ZipEntry)) continue; + + byte[]? bytes = null; + if (entry.Generator != null) + { + bytes = entry.Generator(opts); + } + else if (entry.AbsSource != null) + { + if (File.Exists(entry.AbsSource)) + { + await archive.CreateEntryFromFileAsync(entry.AbsSource, entry.ZipEntry, CompressionLevel.Optimal); + continue; + } + bytes = WidgetFiles.Current.ReadAllBytes(entry.AbsSource); + } + + if (bytes == null) continue; + + var zipEntry = archive.CreateEntry(entry.ZipEntry, CompressionLevel.Optimal); + await using var stream = await zipEntry.OpenAsync(); + await stream.WriteAsync(bytes); + } + } + + public static string ExportsDirectory => Path.GetFullPath(Path.Combine("./exports", "widgets")); + + public static WidgetMeta ReadExistingMeta(Widget widget) + { + var json = WidgetFiles.Current.ReadAllText(widget.HtmlPath + ".json"); + if (json == null) return new WidgetMeta(); + try + { + return JsonSerializer.Deserialize(json, MetaOptions) ?? new WidgetMeta(); + } + catch + { + return new WidgetMeta(); + } + } + + public static string BuildFileName(string author, string group, string name, string version) + { + var parts = new[] { author, WidgetPackPaths.NormalizeGroup(group), name, version } + .Select(p => SanitizeName(p?.Trim() ?? string.Empty).Replace(' ', '-')) + .Where(p => !string.IsNullOrWhiteSpace(p)); + return $"{string.Join('_', parts)}.smw"; + } + + private static string BuildManifest(Widget widget, ExportPlan plan, SmwExportOptions opts) + { + string name = string.IsNullOrWhiteSpace(opts.Name) ? widget.Name : opts.Name; + string group = WidgetPackPaths.NormalizeGroup(opts.Group); + + string packId = WidgetPackPaths.TryResolve(widget.HtmlPath, + out _, out _, out var existingPackId, out _) + ? existingPackId + : WidgetPackPaths.MakePackId(opts.Author, group, name); + + var obj = new + { + version = FormatVersion, + app_version = opts.AppVersion, + exported_at = DateTime.UtcNow, + widget = new + { + pack_id = packId, + name, + author = opts.Author, + group, + widget_version = opts.Version, + tags = opts.Tags, + docsUrl = widget.DocsUrl ?? string.Empty, + type = widget.Type.ToString(), + entry = plan.EntryZipPath, + size = new { width = widget.Width, height = widget.Height }, + scale = new { x = widget.ScaleX, y = widget.ScaleY } + } + }; + return JsonSerializer.Serialize(obj, ManifestOptions); + } + + #endregion + + #region OVERRIDES + + private static List ClaimCssVariables(Widget widget, string cssPath, HashSet consumed) + { + var result = new List(); + string? content = WidgetFiles.Current.ReadAllText(cssPath); + if (content == null) return result; + + foreach (Match match in CssVarRegex().Matches(content)) + { + string name = match.Groups[1].Value.Trim(); + if (!consumed.Add(name)) continue; + var dbVar = widget.CssVariables.FirstOrDefault(v => + string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase)); + if (dbVar != null) result.Add(dbVar); + } + + return result; + } + + private static string OverrideCssValues(string css, List variables) + { + foreach (var variable in variables) + { + if (string.IsNullOrWhiteSpace(variable.Value)) continue; + var pattern = new Regex($@"(--{Regex.Escape(variable.Name)}\s*:\s*)([^;]*)(;)"); + css = pattern.Replace(css, m => $"{m.Groups[1].Value}{variable.Value}{m.Groups[3].Value}", 1); + } + return css; + } + + private static string BuildCssMetaJson(string existingMetaPath, List variables) + { + Dictionary> meta = new(StringComparer.Ordinal); + var existingJson = WidgetFiles.Current.ReadAllText(existingMetaPath); + if (existingJson != null) + { + try + { + meta = JsonSerializer.Deserialize>>(existingJson) ?? meta; + } + catch { /**/ } + } + + foreach (var variable in variables) + { + if (!meta.TryGetValue(variable.Name, out var entry)) + { + entry = new Dictionary(StringComparer.Ordinal); + meta[variable.Name] = entry; + } + entry["type"] = variable.Type.ToString(); + entry["description"] = variable.Description ?? string.Empty; + } + + return JsonSerializer.Serialize(meta, ManifestOptions); + } + + private static string BuildWidgetMetaJson(Widget widget, ExportPlan plan, SmwExportOptions opts) + { + WidgetMeta meta = ReadExistingMeta(widget); + + if (!string.IsNullOrWhiteSpace(opts.Author)) meta.Author = opts.Author; + if (!string.IsNullOrWhiteSpace(widget.DocsUrl)) meta.Url = widget.DocsUrl!; + meta.Width = widget.Width; + meta.Height = widget.Height; + + foreach (var jsVar in widget.JsVariables) + { + if (!meta.Vars.TryGetValue(jsVar.Name, out var metaVar)) + { + metaVar = new WidgetMetaVar(); + meta.Vars[jsVar.Name] = metaVar; + } + + metaVar.Name = jsVar.Name; + metaVar.Type = jsVar.Type; + metaVar.Description = jsVar.Description ?? string.Empty; + + string value = plan.VariableRewrites.TryGetValue(jsVar.Name, out var rewritten) + ? rewritten + : jsVar.Value ?? string.Empty; + + metaVar.Value = ToMetaValue(jsVar.Type, value, metaVar); + } + + return JsonSerializer.Serialize(meta, MetaOptions); + } + + private static object ToMetaValue(WidgetVariableType type, string value, WidgetMetaVar metaVar) + { + var items = value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList(); + + switch (type) + { + case WidgetVariableType.StringSelect: + metaVar.Options = items; + return items.Count > 0 ? items[0] : string.Empty; + + case WidgetVariableType.Boolean: + return bool.TryParse(value, out var b) && b; + + case WidgetVariableType.Int: + case WidgetVariableType.Percent: + return int.TryParse(value, out var i) ? i : 0; + + case WidgetVariableType.Float: + return float.TryParse(value, out var f) ? f : 0f; + + default: + if (type.IsListType()) return items; + return value; + } + } + + #endregion + + #region HELPERS + + private static string? ToContentEntry(string widgetRoot, string absPath) + { + string rel = Path.GetRelativePath(widgetRoot, absPath).Replace('\\', '/'); + if (rel.StartsWith("../") || rel == ".." || Path.IsPathRooted(rel)) return null; + return $"{ContentFolder}/{rel}"; + } + + private static string GetHtmlLinkFor(Widget widget, string cssPath) + { + string baseDir = Path.GetDirectoryName(widget.HtmlPath)!; + foreach (Match match in Widget.CssLinkMatches(WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty)) + { + string href = match.Groups[1].Value; + string resolved = Path.IsPathRooted(href) ? href : Path.Combine(baseDir, href); + if (string.Equals(Path.GetFullPath(resolved), Path.GetFullPath(cssPath), StringComparison.OrdinalIgnoreCase)) + return href; + } + return string.Empty; + } + + private static string RewriteHtmlLinks(string html, Dictionary rewrites) + { + foreach (var (original, replacement) in rewrites) + { + html = html.Replace($"\"{original}\"", $"\"{replacement}\"") + .Replace($"'{original}'", $"'{replacement}'"); + } + return html; + } + + private static string SanitizeName(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + return new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()); + } + + [GeneratedRegex(@"--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);")] + private static partial Regex CssVarRegex(); + + #endregion +} diff --git a/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs b/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs new file mode 100644 index 00000000..ef339d20 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs @@ -0,0 +1,25 @@ +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + +namespace SubathonManager.Data.Widgets; + +public static class WidgetDisplayHelper +{ + public static WidgetDisplayKind GetDisplayKind(this Widget widget) => widget.Type switch + { + WidgetType.Image => WidgetDisplayKind.Image, + WidgetType.Video => WidgetDisplayKind.Video, + _ => WidgetPackPaths.TryResolve(widget.HtmlPath, + out _, out _, out _, out _) + ? WidgetDisplayKind.Widget + : WidgetDisplayKind.UnpackedWidget + }; + + public static string GetLabel(this WidgetDisplayKind kind) => kind switch + { + WidgetDisplayKind.Image => "Image", + WidgetDisplayKind.Video => "Video", + WidgetDisplayKind.UnpackedWidget => "Unpacked Widget", + _ => "Widget" + }; +} diff --git a/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs b/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs new file mode 100644 index 00000000..ea4df22e --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs @@ -0,0 +1,85 @@ +using System.Collections.Concurrent; +using SubathonManager.Core.Interfaces; + +namespace SubathonManager.Data.Widgets; + +public sealed class WidgetPackFileSystem : IWidgetFileSystem +{ + private readonly DiskWidgetFileSystem _disk = new(); + private readonly ConcurrentDictionary _readers = new(StringComparer.OrdinalIgnoreCase); + + public bool Exists(string path) + { + var reader = ReaderFor(path, out var entry); + return reader?.Contains(entry) ?? _disk.Exists(path); + } + + public string? ReadAllText(string path) + { + var reader = ReaderFor(path, out var entry); + return reader != null ? reader.ReadText(entry) : _disk.ReadAllText(path); + } + + public byte[]? ReadAllBytes(string path) + { + var reader = ReaderFor(path, out var entry); + return reader != null ? reader.Read(entry) : _disk.ReadAllBytes(path); + } + + public bool IsPacked(string path) => ReaderFor(path, out _) != null; + + public string? GetRealFilePath(string path) + { + var reader = ReaderFor(path, out var entry); + return reader != null ? reader.Materialize(entry) : _disk.GetRealFilePath(path); + } + + public IEnumerable EnumerateFiles(string directory) + { + var reader = ReaderFor(directory, out var prefix, out var packId, + out var version, requireEntry: false); + + if (reader == null) return _disk.EnumerateFiles(directory); + string normalized = string.IsNullOrEmpty(prefix) ? string.Empty : prefix.TrimEnd('/') + "/"; + + return reader.Entries + .Where(e => normalized.Length == 0 || e.StartsWith(normalized, StringComparison.OrdinalIgnoreCase)) + .Select(e => WidgetPackPaths.EntryPath(packId, version, e)) + .ToList(); + } + + public bool Unpack(string packedPath, string targetDir) + { + var reader = ReaderFor(packedPath, out _, out _, out _, requireEntry: false); + return reader != null && reader.ExtractAll(targetDir); + } + + private WidgetPackReader? ReaderFor(string path, out string entry) => + ReaderFor(path, out entry, out _, out _); + + private WidgetPackReader? ReaderFor(string path, out string entry, out string packId, out string version, + bool requireEntry = true) + { + entry = string.Empty; + packId = string.Empty; + version = string.Empty; + + if (string.IsNullOrWhiteSpace(path) || !WidgetPackPaths.IsMountPath(path)) return null; + + string probe = requireEntry ? path : Path.Combine(path, "_"); + if (!WidgetPackPaths.TryResolve(probe, out var packFile, + out var resolved, out packId, out version)) + return null; + + entry = requireEntry ? resolved : TrimProbe(resolved); + + string cacheDir = Path.Combine(WidgetPackPaths.CacheRoot, packId, version); + return _readers.GetOrAdd(packFile, key => new WidgetPackReader(key, cacheDir)); + } + + private static string TrimProbe(string resolved) + { + int slash = resolved.LastIndexOf('/'); + return slash < 0 ? string.Empty : resolved[..slash]; + } +} diff --git a/SubathonManager.Data/Widgets/WidgetPackInstaller.cs b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs new file mode 100644 index 00000000..9056c2d8 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs @@ -0,0 +1,235 @@ +using System.IO.Compression; +using System.Text.Json; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data.Widgets; + +public class WidgetPackManifest +{ + public string FormatVersion { get; init; } = "1"; + public string AppVersion { get; init; } = string.Empty; + public string PackId { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + + public string Group { get; init; } = WidgetPackPaths.DefaultGroup; + public string Version { get; init; } = "1.0.0"; + public string DocsUrl { get; init; } = string.Empty; + + public List Tags { get; init; } = new(); + + public string Entry { get; init; } = string.Empty; + + public int Width { get; init; } + public int Height { get; init; } + public float ScaleX { get; init; } = 1; + public float ScaleY { get; init; } = 1; +} + +public static class WidgetPackInstaller +{ + public const string ManifestFileName = "widget.json"; + public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string PackFile); + + public static WidgetPackManifest? ReadManifest(string smwPath) + { + try + { + using var stream = new FileStream(smwPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + var entry = archive.Entries.FirstOrDefault(e => + string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); + if (entry == null) return null; + + using var reader = new StreamReader(entry.Open()); + using var doc = JsonDocument.Parse(reader.ReadToEnd()); + return Parse(doc.RootElement, smwPath); + } + catch + { + return null; + } + } + + public static InstalledPack? Install(string smwPath) + { + var manifest = ReadManifest(smwPath); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; + + string packId = manifest.PackId; + string version = WidgetPackPaths.Slug(manifest.Version); + if (string.IsNullOrWhiteSpace(version)) version = "1-0-0"; + + string target = WidgetPackPaths.PackFile(packId, version); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + if (!string.Equals(Path.GetFullPath(smwPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase)) + File.Copy(smwPath, target, overwrite: true); + + string cacheDir = Path.Combine(WidgetPackPaths.CacheRoot, packId, version); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + + WidgetPackPaths.InvalidateVersionCache(packId); + } + catch + { + return null; + } + + string htmlPath = WidgetPackPaths.EntryPath(packId, version, manifest.Entry); + return new InstalledPack(manifest, htmlPath, target); + } + + public static string? DropIntoImports(string smwPath) + { + try + { + if (!File.Exists(smwPath)) return null; + + var installed = Install(smwPath); + if (installed != null) return installed.PackFile; + + Directory.CreateDirectory(WidgetPackPaths.PackedRoot); + string target = Path.Combine(WidgetPackPaths.PackedRoot, Path.GetFileName(smwPath)); + if (string.Equals(Path.GetFullPath(smwPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase)) + return target; + + File.Copy(smwPath, target, overwrite: true); + return target; + } + catch + { + return null; + } + } + + public static int SweepCache(IEnumerable liveWidgetPaths) + { + string cacheRoot = WidgetPackPaths.CacheRoot; + if (!Directory.Exists(cacheRoot)) return 0; + + var live = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in liveWidgetPaths) + { + if (WidgetPackPaths.TryResolve(path, out _, out _, out var packId, out var version)) + live.Add(Path.Combine(cacheRoot, packId, version)); + } + + int removed = 0; + + try + { + foreach (var packDir in Directory.EnumerateDirectories(cacheRoot)) + { + foreach (var versionDir in Directory.EnumerateDirectories(packDir)) + { + if (live.Contains(versionDir)) continue; + try + { + Directory.Delete(versionDir, recursive: true); + removed++; + } + catch { /**/ } + } + + try + { + if (!Directory.EnumerateFileSystemEntries(packDir).Any()) + Directory.Delete(packDir); + } + catch { /**/ } + } + } + catch { /**/ } + + return removed; + } + + public static string? FindNewerVersion(string widgetHtmlPath) + { + if (!WidgetPackPaths.TryResolve(widgetHtmlPath, out _, out _, out var packId, out var version)) + return null; + + string? best = null; + foreach (var candidate in WidgetPackPaths.InstalledVersions(packId)) + { + if (WidgetPackPaths.CompareVersions(candidate, version) <= 0) continue; + if (best == null || WidgetPackPaths.CompareVersions(candidate, best) > 0) best = candidate; + } + + return best; + } + + private static WidgetPackManifest Parse(JsonElement root, string smwPath) + { + var widget = root.TryGetProperty("widget", out var w) ? w : default; + + string name = Str(widget, "name"); + if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smwPath); + string author = Str(widget, "author"); + string group = WidgetPackPaths.NormalizeGroup(Str(widget, "group")); + + string packId = Str(widget, "pack_id"); + if (string.IsNullOrWhiteSpace(packId)) packId = WidgetPackPaths.MakePackId(author, group, name); + + string version = Str(widget, "widget_version"); + if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; + + var size = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("size", + out var s) ? s : default; + var scale = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("scale", + out var c) ? c : default; + + return new WidgetPackManifest + { + FormatVersion = Str(root, "version"), + AppVersion = Str(root, "app_version"), + PackId = packId, + Name = name, + Author = author, + Group = group, + Version = version, + DocsUrl = Str(widget, "docsUrl"), + Tags = StrList(widget, "tags"), + Entry = Str(widget, "entry").Replace('\\', '/').TrimStart('/'), + Width = Int(size, "width", 400), + Height = Int(size, "height", 400), + ScaleX = Flt(scale, "x", 1f), + ScaleY = Flt(scale, "y", 1f) + }; + } + + private static string Str(JsonElement el, string name) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() ?? string.Empty + : string.Empty; + + private static List StrList(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || + !el.TryGetProperty(name, out var v) || + v.ValueKind != JsonValueKind.Array) + return new List(); + + return v.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + } + + private static int Int(JsonElement el, string name, int fallback) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, + out var v) && v.TryGetInt32(out var i) + ? i + : fallback; + + private static float Flt(JsonElement el, string name, float fallback) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, + out var v) && v.TryGetSingle(out var f) && f > 0 + ? f + : fallback; +} diff --git a/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs b/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs new file mode 100644 index 00000000..052a4d23 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs @@ -0,0 +1,100 @@ +namespace SubathonManager.Data.Widgets; + +internal static class WidgetPackMemoryCache +{ + private const long Budget = 32 * 1024 * 1024; + public const long MaxEntrySize = 2 * 1024 * 1024; + + private static readonly Lock CacheLock = new(); + private static readonly Dictionary> Map = new(StringComparer.OrdinalIgnoreCase); + + private static readonly LinkedList Order = []; + + private static long _size; + + private sealed class CacheItem(string key, byte[] data) + { + public string Key { get; } = key; + public byte[] Data { get; } = data; + } + + public static string MakeKey(string packFile, string entry) => $"{packFile}|{entry}"; + + public static bool TryGet(string key, out byte[] data) + { + lock (CacheLock) + { + if (!Map.TryGetValue(key, out var node)) + { + data = []; + return false; + } + + Order.Remove(node); + Order.AddFirst(node); + data = node.Value.Data; + return true; + } + } + + public static void Set(string key, byte[] data) + { + if (data.LongLength >= MaxEntrySize) return; + + lock (CacheLock) + { + if (Map.TryGetValue(key, out var existing)) + { + _size -= existing.Value.Data.LongLength; + Order.Remove(existing); + Map.Remove(key); + } + + var node = Order.AddFirst(new CacheItem(key, data)); + Map[key] = node; + _size += data.LongLength; + + while (_size > Budget && Order.Last is { } tail) + { + Order.RemoveLast(); + Map.Remove(tail.Value.Key); + _size -= tail.Value.Data.LongLength; + } + } + } + + public static void InvalidatePack(string packFile) + { + string prefix = packFile + "|"; + + lock (CacheLock) + { + var doomed = Map.Keys + .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var key in doomed) + { + if (!Map.TryGetValue(key, out var node)) continue; + Order.Remove(node); + Map.Remove(key); + _size -= node.Value.Data.LongLength; + } + } + } + + public static void Clear() + { + lock (CacheLock) + { + Map.Clear(); + Order.Clear(); + _size = 0; + } + } + + internal static long CurrentSize + { + get { lock (CacheLock) return _size; } + } +} diff --git a/SubathonManager.Data/Widgets/WidgetPackPaths.cs b/SubathonManager.Data/Widgets/WidgetPackPaths.cs new file mode 100644 index 00000000..fb7329a3 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackPaths.cs @@ -0,0 +1,191 @@ +using System.Text.RegularExpressions; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data.Widgets; + +public static partial class WidgetPackPaths +{ + public const string PackExtension = ".smw"; + + public static string ImportsRoot => Path.GetFullPath(Path.Combine("./imports", "widgets")); + public static string PackedRoot => Path.Combine(ImportsRoot, "packed"); + public static string UnpackedRoot => Path.Combine(ImportsRoot, "unpacked"); + public static string CacheRoot => Path.GetFullPath(Path.Combine("./cache", "widgets")); + + public static string MountRoot(string packId, string version) + => Path.Combine(PackedRoot, packId, version); + + public static string PackFile(string packId, string version) + => Path.Combine(PackedRoot, packId, version + PackExtension); + + public static string PackFolder(string packId) => Path.Combine(PackedRoot, packId); + + private static readonly Lock VersionCacheLock = new(); + private static readonly Dictionary> VersionCache = new(StringComparer.OrdinalIgnoreCase); + + public static List InstalledVersions(string packId) + { + lock (VersionCacheLock) + { + if (VersionCache.TryGetValue(packId, out var cached)) return cached; + + var versions = new List(); + string folder = PackFolder(packId); + + if (Directory.Exists(folder)) + { + try + { + versions = Directory.EnumerateFiles(folder, "*" + PackExtension) + .Select(Path.GetFileNameWithoutExtension) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!) + .ToList(); + } + catch { /**/ } + } + + VersionCache[packId] = versions; + return versions; + } + } + + public static string DisplayVersion(string? version) + => string.IsNullOrWhiteSpace(version) + ? string.Empty + : VersionSeparatorRegex().Replace(version, "."); + + public static void InvalidateVersionCache(string? packId = null) + { + lock (VersionCacheLock) + { + if (packId == null) VersionCache.Clear(); + else VersionCache.Remove(packId); + } + } + + public static int CompareVersions(string left, string right) + { + var a = VersionSegments(left); + var b = VersionSegments(right); + + for (int i = 0; i < Math.Max(a.Count, b.Count); i++) + { + long x = i < a.Count ? a[i] : 0; + long y = i < b.Count ? b[i] : 0; + if (x != y) return x.CompareTo(y); + } + + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase); + } + + private static List VersionSegments(string version) + => VersionSegmentRegex().Matches(version ?? string.Empty) + .Select(m => long.TryParse(m.Value, out var n) ? n : 0L) + .ToList(); + public const string DefaultGroup = "widgets"; + + public static string NormalizeGroup(string? group) + => string.IsNullOrWhiteSpace(group) ? DefaultGroup : group.Trim(); + + public static string UnpackRoot(string author, string group, string name, string version) + { + string authorFolder = Slug(author); + if (string.IsNullOrEmpty(authorFolder)) authorFolder = "unknown"; + + string groupFolder = Slug(NormalizeGroup(group)); + if (string.IsNullOrEmpty(groupFolder)) groupFolder = DefaultGroup; + + string nameFolder = Slug(name); + if (string.IsNullOrEmpty(nameFolder)) nameFolder = "widget"; + + string versionFolder = SanitizeSegment(version); + if (string.IsNullOrEmpty(versionFolder)) versionFolder = "1.0.0"; + + return Path.Combine(UnpackedRoot, authorFolder, groupFolder, nameFolder, versionFolder); + } + + private static string SanitizeSegment(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + var invalid = Path.GetInvalidFileNameChars(); + return new string(value.Trim().Select(c => invalid.Contains(c) ? '_' : c).ToArray()); + } + + public static string EntryPath(string packId, string version, string entry) + => Path.Combine(MountRoot(packId, version), entry.Replace('/', Path.DirectorySeparatorChar)); + + public static bool TryResolve(string widgetPath, out string packFile, out string entry, + out string packId, out string version) + { + packFile = string.Empty; + entry = string.Empty; + packId = string.Empty; + version = string.Empty; + + if (string.IsNullOrWhiteSpace(widgetPath)) return false; + + string full; + try { full = Path.GetFullPath(widgetPath); } + catch { return false; } + + string root = PackedRoot; + if (!full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return false; + + var parts = full[(root.Length + 1)..] + .Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length < 3) return false; + + packId = parts[0]; + version = parts[1]; + entry = string.Join('/', parts.Skip(2)); + packFile = PackFile(packId, version); + + return File.Exists(packFile); + } + + public static bool IsMountPath(string widgetPath) + { + if (string.IsNullOrWhiteSpace(widgetPath)) return false; + try + { + return Path.GetFullPath(widgetPath) + .StartsWith(PackedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + + public static string MakePackId(string author, string group, string name) + { + string n = Slug(name); + if (string.IsNullOrEmpty(n)) n = "widget"; + + var parts = new[] { Slug(author), Slug(NormalizeGroup(group)), n } + .Where(p => !string.IsNullOrEmpty(p)); + + return string.Join('.', parts); + } + + public static string Slug(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + string slug = SlugStripRegex().Replace(value.Trim().ToLowerInvariant(), "-"); + slug = SlugCollapseRegex().Replace(slug, "-").Trim('-'); + return slug; + } + + [GeneratedRegex(@"[^a-z0-9]+")] + private static partial Regex SlugStripRegex(); + + [GeneratedRegex(@"-{2,}")] + private static partial Regex SlugCollapseRegex(); + + [GeneratedRegex(@"\d+")] + private static partial Regex VersionSegmentRegex(); + + [GeneratedRegex(@"(?<=\d)-(?=\d)")] + private static partial Regex VersionSeparatorRegex(); +} diff --git a/SubathonManager.Data/Widgets/WidgetPackReader.cs b/SubathonManager.Data/Widgets/WidgetPackReader.cs new file mode 100644 index 00000000..93df97bd --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackReader.cs @@ -0,0 +1,163 @@ +using System.IO.Compression; +using System.Text; + +namespace SubathonManager.Data.Widgets; + +internal sealed class WidgetPackReader(string packFile, string cacheDir) +{ + private const long MaterializeThreshold = WidgetPackMemoryCache.MaxEntrySize; + + private readonly Lock _indexLock = new(); + private Dictionary? _index; + private DateTime _indexedAt; + + private readonly record struct IndexedEntry(string FullName, long Length); + + private Dictionary Index + { + get + { + lock (_indexLock) + { + var stamp = File.Exists(packFile) ? File.GetLastWriteTimeUtc(packFile) : DateTime.MinValue; + if (_index != null && stamp == _indexedAt) return _index; + + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; // dir + index[Normalize(entry.FullName)] = new IndexedEntry(entry.FullName, entry.Length); + } + } + catch { /**/ } + + WidgetPackMemoryCache.InvalidatePack(packFile); + _index = index; + _indexedAt = stamp; + return index; + } + } + } + + public bool Contains(string entry) => Index.ContainsKey(Normalize(entry)); + + public IEnumerable Entries => Index.Keys; + + public long LengthOf(string entry) + => Index.TryGetValue(Normalize(entry), out var found) ? found.Length : -1; + + public byte[]? Read(string entry) + { + string key = Normalize(entry); + string cacheKey = WidgetPackMemoryCache.MakeKey(packFile, key); + + if (WidgetPackMemoryCache.TryGet(cacheKey, out var cached)) return cached; + if (!Index.TryGetValue(key, out var indexed)) return null; + + byte[]? bytes = ReadRaw(indexed.FullName); + if (bytes == null) return null; + + if (indexed.Length < MaterializeThreshold) + WidgetPackMemoryCache.Set(cacheKey, bytes); + + return bytes; + } + + public string? ReadText(string entry) + { + var bytes = Read(entry); + if (bytes == null) return null; + return bytes is [0xEF, 0xBB, 0xBF, ..] + ? Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3) + : Encoding.UTF8.GetString(bytes); + } + + public string? Materialize(string entry) + { + string key = Normalize(entry); + if (!Index.TryGetValue(key, out var indexed)) return null; + if (indexed.Length < MaterializeThreshold) return null; + + string target = Path.Combine(cacheDir, key.Replace('/', Path.DirectorySeparatorChar)); + + try + { + var info = new FileInfo(target); + if (info.Exists && info.Length == indexed.Length) return target; + + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + string temp = target + ".partial"; + using (var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + { + var zipEntry = archive.GetEntry(indexed.FullName); + if (zipEntry == null) return null; + zipEntry.ExtractToFile(temp, overwrite: true); + } + + File.Move(temp, target, overwrite: true); + return target; + } + catch + { + return null; + } + } + + public bool ExtractAll(string targetDir) + { + try + { + Directory.CreateDirectory(targetDir); + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; + + string destination = Path.GetFullPath( + Path.Combine(targetDir, Normalize(entry.FullName).Replace('/', Path.DirectorySeparatorChar))); + + if (!destination.StartsWith(Path.GetFullPath(targetDir) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)) + continue; + + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + entry.ExtractToFile(destination, overwrite: true); + } + + return true; + } + catch + { + return false; + } + } + + private byte[]? ReadRaw(string fullName) + { + try + { + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var entry = archive.GetEntry(fullName); + if (entry == null) return null; + + using var entryStream = entry.Open(); + using var buffer = new MemoryStream(entry.Length > 0 ? (int)Math.Min(entry.Length, int.MaxValue) : 0); + entryStream.CopyTo(buffer); + return buffer.ToArray(); + } + catch + { + return null; + } + } + + private static string Normalize(string entry) => entry.Replace('\\', '/').TrimStart('/'); +} \ No newline at end of file diff --git a/SubathonManager.Server/AspNetContextAdapter.cs b/SubathonManager.Server/AspNetContextAdapter.cs index b307d9a6..5dffe02a 100644 --- a/SubathonManager.Server/AspNetContextAdapter.cs +++ b/SubathonManager.Server/AspNetContextAdapter.cs @@ -3,6 +3,7 @@ using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; +using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; @@ -49,6 +50,14 @@ public async Task ServeFile(string fullPath, string contentType) await ctx.Response.SendFileAsync(fullPath); } + public async Task ServeBytes(byte[] data, string contentType) + { + ctx.Response.ContentType = contentType; + ctx.Response.ContentLength = data.Length; + AddCorsHeaders(ctx.Response); + await ctx.Response.Body.WriteAsync(data); + } + private static void AddCorsHeaders(HttpResponse response) { response.Headers["Access-Control-Allow-Origin"] = "*"; diff --git a/SubathonManager.Server/Interfaces/IHttpContext.cs b/SubathonManager.Server/Interfaces/IHttpContext.cs index edcc6252..9f375fe6 100644 --- a/SubathonManager.Server/Interfaces/IHttpContext.cs +++ b/SubathonManager.Server/Interfaces/IHttpContext.cs @@ -1,6 +1,6 @@ using System.Text; using System.Net.WebSockets; -namespace SubathonManager.Server; +namespace SubathonManager.Server.Interfaces; public interface IHttpContext { @@ -16,8 +16,7 @@ public interface IHttpContext bool IsWebSocket { get; } Task? AcceptWebSocketAsync(string? subProtocol = null); - Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null); - Task ServeFile(string fullPath, string contentType); + Task ServeBytes(byte[] data, string contentType); } \ No newline at end of file diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index 098ed685..698d43d0 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -6,6 +6,8 @@ using SubathonManager.Core.Enums; using SubathonManager.Integration; using SubathonManager.Data; +using SubathonManager.Server.Interfaces; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Server; diff --git a/SubathonManager.Server/WebServer.Overlays.cs b/SubathonManager.Server/WebServer.Overlays.cs index a7111f23..ea01c1ec 100644 --- a/SubathonManager.Server/WebServer.Overlays.cs +++ b/SubathonManager.Server/WebServer.Overlays.cs @@ -5,6 +5,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; +using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; @@ -59,11 +61,11 @@ internal async Task HandleWidgetRequest(IHttpContext ctx) ? widget.HtmlPath : Path.Combine(folder, relativePath.Replace('/', Path.DirectorySeparatorChar)); - if (File.Exists(filePath)) + if (WidgetFiles.Current.Exists(filePath)) { if (Path.GetExtension(filePath).Equals(".html", StringComparison.OrdinalIgnoreCase)) { - string html = await File.ReadAllTextAsync(filePath); + string html = WidgetFiles.Current.ReadAllText(filePath) ?? string.Empty; var cssOverrides = new StringBuilder(); cssOverrides.AppendLine(GetWebsocketInjectionScript()); @@ -153,8 +155,20 @@ function loadBunnyFont(fontName) { await ctx.WriteResponse(200, html, true, "text/html"); return; } - await ctx.ServeFile(filePath, GetContentType(filePath)); - return; + + var realPath = WidgetFiles.Current.GetRealFilePath(filePath); + if (realPath != null) + { + await ctx.ServeFile(realPath, GetContentType(filePath)); + return; + } + + var bytes = WidgetFiles.Current.ReadAllBytes(filePath); + if (bytes != null) + { + await ctx.ServeBytes(bytes, GetContentType(filePath)); + return; + } } } } diff --git a/SubathonManager.Server/WebServer.WebSocket.cs b/SubathonManager.Server/WebServer.WebSocket.cs index df045a35..190af7dc 100644 --- a/SubathonManager.Server/WebServer.WebSocket.cs +++ b/SubathonManager.Server/WebServer.WebSocket.cs @@ -10,6 +10,7 @@ using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.Integration; +using SubathonManager.Server.Interfaces; using SubathonManager.Services; // ReSharper disable NullableWarningSuppressionIsUsed diff --git a/SubathonManager.Server/WebServer.cs b/SubathonManager.Server/WebServer.cs index 67cb9a07..3cb17776 100644 --- a/SubathonManager.Server/WebServer.cs +++ b/SubathonManager.Server/WebServer.cs @@ -9,6 +9,7 @@ using SubathonManager.Core; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; diff --git a/SubathonManager.Tests/ServerUnitTests/MockHttpContext.cs b/SubathonManager.Tests/ServerUnitTests/MockHttpContext.cs index 4cb8987c..e17c1822 100644 --- a/SubathonManager.Tests/ServerUnitTests/MockHttpContext.cs +++ b/SubathonManager.Tests/ServerUnitTests/MockHttpContext.cs @@ -1,6 +1,6 @@ using System.Text; using System.Net.WebSockets; -using SubathonManager.Server; +using SubathonManager.Server.Interfaces; namespace SubathonManager.Tests.ServerUnitTests; @@ -27,6 +27,16 @@ public Task ServeFile(string fullPath, string contentType) { return Task.CompletedTask; } + + public byte[]? ServedBytes { get; private set; } + + public Task ServeBytes(byte[] data, string contentType) + { + ServedBytes = data; + StatusCode = 200; + ResponseBodyStream.Write(data); + return Task.CompletedTask; + } public Task? AcceptWebSocketAsync(string? subProtocol = null) { diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs index 7ff4a2b9..87b0cac5 100644 --- a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs +++ b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs @@ -9,6 +9,7 @@ using SubathonManager.Data; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Server.Interfaces; using SubathonManager.Tests.Utility; // ReSharper disable NullableWarningSuppressionIsUsed diff --git a/SubathonManager.UI/App.axaml.cs b/SubathonManager.UI/App.axaml.cs index c578510b..1ba7b146 100644 --- a/SubathonManager.UI/App.axaml.cs +++ b/SubathonManager.UI/App.axaml.cs @@ -15,6 +15,7 @@ using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; using SubathonManager.Data; +using SubathonManager.Data.Widgets; using SubathonManager.Server; using Avalonia.Platform.Storage; using SubathonManager.Services; @@ -59,6 +60,8 @@ public override void OnFrameworkInitializationCompleted() string folder = Path.GetFullPath(Path.Combine(string.Empty, "data")); Directory.CreateDirectory(folder); + WidgetFiles.Current = new WidgetPackFileSystem(); + try { services.SetupInfrastructure(); @@ -71,6 +74,7 @@ public override void OnFrameworkInitializationCompleted() config.LoadOrCreateDefault(); config.MigrateConfig(); MigrateSecureStore(config); + SweepWidgetPackCache(); bool bitsAsDonationCheck = config.GetBool("Currency", "BitsLikeAsDonation", false); Utils.DonationSettings["BitsLikeAsDonation"] = bitsAsDonationCheck; @@ -191,6 +195,9 @@ private void OnActivationReceived(ActivationRequest request) case Platform.ActivationKind.SmoFile: Utils.PendingOverlayImportPath = request.Payload; break; + case Platform.ActivationKind.SmwFile: + Utils.PendingWidgetPackImportPath = request.Payload; + break; case Platform.ActivationKind.OAuth: ProtocolParser.Parse([request.Payload]); break; @@ -273,6 +280,24 @@ private void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e) _logger?.LogInformation("======== Subathon Manager exit ========"); // } + + private void SweepWidgetPackCache() + { + try + { + var factory = AppServices.Provider.GetRequiredService>(); + using var db = factory.CreateDbContext(); + var paths = db.Widgets.AsNoTracking().Select(w => w.HtmlPath).ToList(); + + int removed = WidgetPackInstaller.SweepCache(paths); + if (removed > 0) + _logger?.LogInformation("Swept {Count} stale widget pack cache folder(s)", removed); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to sweep widget pack cache"); + } + } private void MigrateSecureStore(IConfig config) { diff --git a/SubathonManager.UI/Converters/Converters.cs b/SubathonManager.UI/Converters/Converters.cs index cda2cba8..bc7c98ce 100644 --- a/SubathonManager.UI/Converters/Converters.cs +++ b/SubathonManager.UI/Converters/Converters.cs @@ -5,6 +5,7 @@ using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Models; +using SubathonManager.Data.Widgets; namespace SubathonManager.UI.Converters; @@ -311,6 +312,8 @@ public class WidgetAssetTypeLabelConverter : IValueConverter public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch { + // wait this actually works + Widget widget => widget.GetDisplayKind().GetLabel(), WidgetType.Image => "Image", WidgetType.Video => "Video", _ => "Widget" @@ -320,6 +323,44 @@ public object ConvertBack(object? value, Type targetType, object? parameter, Cul => throw new NotImplementedException(); } +public class WidgetIsHtmlConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is WidgetType.Html; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +public class WidgetIsPackedConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is string path && WidgetPackPaths.TryResolve(path, out _, out _, out _, out _); + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +public class WidgetHasUpdateConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is string path && WidgetPackInstaller.FindNewerVersion(path) != null; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + +public class WidgetUpdateLabelConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is string path && WidgetPackInstaller.FindNewerVersion(path) is { } newer + ? $"Update to {WidgetPackPaths.DisplayVersion(newer)}" + : string.Empty; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} + public class PromptRunStatusColorConverter : IValueConverter { public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) diff --git a/SubathonManager.UI/EditRouteWindow.Handlers.cs b/SubathonManager.UI/EditRouteWindow.Handlers.cs index 683acf56..59ed164d 100644 --- a/SubathonManager.UI/EditRouteWindow.Handlers.cs +++ b/SubathonManager.UI/EditRouteWindow.Handlers.cs @@ -7,7 +7,6 @@ using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Threading; -using FluentAvalonia.UI.Controls; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -17,10 +16,12 @@ using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Data; +using SubathonManager.Data.Widgets; using SubathonManager.UI.Controls; using SubathonManager.UI.UiUtils; using SubathonManager.UI.Validation; using SubathonManager.UI.Views; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.UI; public partial class EditRouteWindow @@ -165,8 +166,12 @@ private void OpenOverlayInBrowser_Click(object? sender, RoutedEventArgs e) } private async void ImportWidgetButton_Click(object? sender, RoutedEventArgs e) - => await RunImportAsync("Select widget HTML file(s)", - new[] { new FilePickerFileType("HTML Widgets") { Patterns = new[] { "*.html", "*.htm" } } }); + => await RunImportAsync("Select widget file(s)", new[] + { + new FilePickerFileType("Widgets") { Patterns = new[] { "*.html", "*.htm", "*.smw" } }, + new FilePickerFileType("Widget Packages (*.smw)") { Patterns = new[] { "*.smw" } }, + new FilePickerFileType("HTML Widgets") { Patterns = new[] { "*.html", "*.htm" } } + }); private async void ImportAssetButton_Click(object? sender, RoutedEventArgs e) => await RunImportAsync("Select asset file(s)", new[] @@ -200,19 +205,34 @@ private async Task RunImportAsync(string title, FilePickerFileType[] filters) await using var db = await _factory.CreateDbContextAsync(); var helper = new WidgetEntityHelper(_factory, null); + bool importedPack = false; + foreach (var file in files) { var path = file.Path.LocalPath; + _lastFolder = Path.GetDirectoryName(path)!; try { - await ImportSingleWidgetAsync(path, db, helper); + if (path.EndsWith(WidgetPackPaths.PackExtension, StringComparison.OrdinalIgnoreCase)) + { + await ImportWidgetPackAsync(path, db, helper); + importedPack = true; + } + else + { + await ImportSingleWidgetAsync(path, db, helper); + } } catch (Exception ex) { _logger?.LogError(ex, "Failed to import widget file {Path}", path); } - _lastFolder = Path.GetDirectoryName(path)!; } + + if (importedPack) + foreach (var existing in _widgets.ToList()) + ReseatWidgetCard(existing); + await RefreshWidgetZIndicesAsync(); if (_route != null) OverlayEvents.RaiseOverlayRefreshRequested(_route.Id); RefreshWebView(); @@ -379,7 +399,7 @@ private void EditWidget_Click(object? sender, RoutedEventArgs e) if (widget == null) return; if (_erroredWidgets.Contains(widget.Id)) { - _ = ShowWidgetErrorPopup(widget); + ShowWidgetErrorPopup(widget); return; } PopulateWidgetEditor(widget); @@ -390,7 +410,7 @@ private void WidgetCard_DoubleTapped(object? sender, TappedEventArgs e) if (sender is not Control { DataContext: Widget widget }) return; if (_erroredWidgets.Contains(widget.Id)) { - _ = ShowWidgetErrorPopup(widget); + ShowWidgetErrorPopup(widget); return; } PopulateWidgetEditor(widget); @@ -429,35 +449,299 @@ private async Task DeleteWidgetAsync(Widget wi) } } - private async Task ShowWidgetErrorPopup(Widget widget) + private void ShowWidgetErrorPopup(Widget widget) + => ShowConfirmFlyout(WidgetAnchor(widget), + "Widget Error", + $"Could not find the file for \"{widget.Name}\".", + widget.HtmlPath, + "Delete Widget", + destructive: true, + onConfirm: () => _ = DeleteWidgetAsync(widget)); + + #region CARD POPUPS + + private Control WidgetAnchor(Widget widget) + => _widgetCardBorders.TryGetValue(widget.Id, out var border) ? border : this; + + private StackPanel BuildFlyoutPanel(string title, string body, string? detail) { - var panel = new StackPanel { Orientation = global::Avalonia.Layout.Orientation.Vertical, Margin = new global::Avalonia.Thickness(4, 4, 4, 8) }; + var panel = new StackPanel { Spacing = 6, MaxWidth = 320 }; + + panel.Children.Add(new TextBlock + { + Text = title, + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap + }); + panel.Children.Add(new TextBlock { - Text = $"Error loading widget \"{widget.Name}\". Could not find file", - TextWrapping = TextWrapping.Wrap, - Width = 320 + Text = body, + FontSize = 12, + TextWrapping = TextWrapping.Wrap }); - var pathText = new TextBlock + + if (!string.IsNullOrWhiteSpace(detail)) { - Text = widget.HtmlPath, - TextWrapping = TextWrapping.Wrap, - Width = 320, - Margin = new global::Avalonia.Thickness(0, 6, 0, 0) + var detailText = new TextBlock + { + Text = detail, + FontSize = 11, + TextWrapping = TextWrapping.Wrap, + Foreground = TertiaryTextBrush() + }; + ToolTip.SetTip(detailText, detail); + panel.Children.Add(detailText); + } + + return panel; + } + + private IBrush TertiaryTextBrush() + => this.TryFindResource("UiBrushTextTertiary", ActualThemeVariant, out var res) && res is IBrush brush + ? brush + : Brushes.Gray; + + private void ShowMessageFlyout(Control anchor, string title, string message, string? detail = null) + => new Flyout + { + Placement = PlacementMode.Top, + Content = BuildFlyoutPanel(title, message, detail) + }.ShowAt(anchor); + + private void ShowConfirmFlyout(Control anchor, string title, string body, string? detail, + string confirmText, bool destructive, Action onConfirm) + { + var flyout = new Flyout { Placement = PlacementMode.Top }; + var panel = BuildFlyoutPanel(title, body, detail); + + var confirmButton = new Button { Content = confirmText, MinWidth = 90 }; + confirmButton.Classes.Add(destructive ? "danger" : "accent"); + + var cancelButton = new Button { Content = "Cancel", MinWidth = 90 }; + + var row = new StackPanel + { + Orientation = global::Avalonia.Layout.Orientation.Horizontal, + Spacing = 8, + Margin = new global::Avalonia.Thickness(0, 6, 0, 0), + HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Right }; - ToolTip.SetTip(pathText, widget.HtmlPath); - panel.Children.Add(pathText); + row.Children.Add(cancelButton); + row.Children.Add(confirmButton); + panel.Children.Add(row); - var dialog = new FAContentDialog + cancelButton.Click += (_, _) => flyout.Hide(); + confirmButton.Click += (_, _) => { - Title = "Widget Error", - Content = panel, - PrimaryButtonText = "Delete Widget", - CloseButtonText = "OK" + flyout.Hide(); + onConfirm(); }; - if (await dialog.ShowAsync() == FAContentDialogResult.Primary) - await DeleteWidgetAsync(widget); + flyout.Content = panel; + flyout.ShowAt(anchor); + } + + #endregion + + private async void ExportWidget_Click(object? sender, RoutedEventArgs e) + { + try + { + var widget = (sender as Control)?.Tag as Widget ?? GetWidgetFromSender(sender); + if (widget == null || widget.Type.IsAsset()) return; + + if (!WidgetFiles.Current.Exists(widget.HtmlPath)) + { + ShowWidgetErrorPopup(widget); + return; + } + + var dialog = new ExportWidgetDialog(widget); + await dialog.ShowDialog(this); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to open widget export dialog"); + } + } + + private void OpenWidgetFolder_Click(object? sender, RoutedEventArgs e) + { + var widget = (sender as Control)?.Tag as Widget ?? GetWidgetFromSender(sender); + if (widget == null) return; + + string target = WidgetPackPaths.TryResolve(widget.HtmlPath, out var packFile, out _, out _, out _) + ? packFile + : widget.HtmlPath; + + if (!UiHelpers.RevealInFileManager(target)) + _logger?.LogError("Failed to open folder for {Path}", target); + } + + private void UpdateWidgetVersion_Click(object? sender, RoutedEventArgs e) + { + if (sender is not Button button) return; + + var widget = button.Tag as Widget ?? GetWidgetFromSender(sender); + if (widget == null) return; + + if (!WidgetPackPaths.TryResolve(widget.HtmlPath, out _, out var entry, out var packId, out var version)) + return; + + var newer = WidgetPackInstaller.FindNewerVersion(widget.HtmlPath); + if (newer == null) return; + + ShowConfirmFlyout(button, + $"Update \"{widget.Name}\"?", + $"{WidgetPackPaths.DisplayVersion(version)} - {WidgetPackPaths.DisplayVersion(newer)}", + "Your configured values are kept if the new version still has a matching variable. " + + "Anything it dropped or renamed will be lost.", + "Update", + destructive: false, + onConfirm: () => _ = ApplyWidgetVersionUpdateAsync(widget, packId, entry, newer, button)); + } + + private async Task ApplyWidgetVersionUpdateAsync(Widget widget, string packId, string entry, string newer, + Control anchor) + { + try + { + var manifest = WidgetPackInstaller.ReadManifest(WidgetPackPaths.PackFile(packId, newer)); + string newEntry = string.IsNullOrWhiteSpace(manifest?.Entry) ? entry : manifest.Entry; + string newPath = WidgetPackPaths.EntryPath(packId, newer, newEntry); + + if (!WidgetFiles.Current.Exists(newPath)) + { + ShowMessageFlyout(anchor, "Update Failed", + $"Version {WidgetPackPaths.DisplayVersion(newer)} does not contain \"{newEntry}\"."); + return; + } + + await using var db = await _factory.CreateDbContextAsync(); + var tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == widget.Id); + if (tracked == null) return; + + tracked.HtmlPath = newPath; + await db.SaveChangesAsync(); + widget.HtmlPath = newPath; + + var helper = new WidgetEntityHelper(_factory, null); + helper.SyncCssVariables(widget); + helper.SyncJsVariables(widget); + + ReseatWidgetCard(widget); + + if (_route != null) OverlayEvents.RaiseOverlayRefreshRequested(_route.Id); + RefreshWebView(); + if (_selectedWidget?.Id == widget.Id) PopulateWidgetEditor(widget); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to update widget {Name} to version {Version}", widget.Name, newer); + ShowMessageFlyout(anchor, "Update Failed", ex.Message); + } + } + + private static void ShowMessageFlyout(Control anchor, string title, string message) + { + var panel = new StackPanel { Spacing = 6, MaxWidth = 300 }; + panel.Children.Add(new TextBlock + { + Text = title, + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap + }); + panel.Children.Add(new TextBlock + { + Text = message, + FontSize = 12, + TextWrapping = TextWrapping.Wrap + }); + + new Flyout { Placement = PlacementMode.Top, Content = panel }.ShowAt(anchor); + } + + private void ReseatWidgetCard(Widget widget) + { + int index = _widgets.IndexOf(widget); + if (index < 0) return; + _widgets.RemoveAt(index); + _widgets.Insert(index, widget); + } + + private void UnpackWidget_Click(object? sender, RoutedEventArgs e) + { + var widget = (sender as Control)?.Tag as Widget ?? GetWidgetFromSender(sender); + if (widget == null) return; + + if (!WidgetPackPaths.TryResolve(widget.HtmlPath, out var packFile, out var entry, out _, out var version)) + return; + + var manifest = WidgetPackInstaller.ReadManifest(packFile); + string targetDir = WidgetPackPaths.UnpackRoot( + manifest?.Author ?? string.Empty, + manifest?.Group ?? string.Empty, + string.IsNullOrWhiteSpace(manifest?.Name) ? widget.Name : manifest.Name, + string.IsNullOrWhiteSpace(manifest?.Version) ? version : manifest.Version); + + bool wouldOverwrite = Directory.Exists(targetDir) && + Directory.EnumerateFileSystemEntries(targetDir).Any(); + + if (!wouldOverwrite) + { + _ = ApplyUnpackAsync(widget, entry, targetDir); + return; + } + + ShowConfirmFlyout(WidgetAnchor(widget), + "Unpack Widget", + "This folder already has content. Unpacking will overwrite the files in it.", + targetDir, + "Unpack", + destructive: true, + onConfirm: () => _ = ApplyUnpackAsync(widget, entry, targetDir)); + } + + private async Task ApplyUnpackAsync(Widget widget, string entry, string targetDir) + { + var anchor = WidgetAnchor(widget); + + try + { + if (WidgetFiles.Current is not WidgetPackFileSystem packs || !packs.Unpack(widget.HtmlPath, targetDir)) + { + ShowMessageFlyout(anchor, "Unpack Failed", "Could not extract the widget package."); + return; + } + + string loosePath = Path.Combine(targetDir, entry.Replace('/', Path.DirectorySeparatorChar)); + + await using var db = await _factory.CreateDbContextAsync(); + var tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == widget.Id); + if (tracked == null) return; + + tracked.HtmlPath = loosePath; + await db.SaveChangesAsync(); + widget.HtmlPath = loosePath; + + var helper = new WidgetEntityHelper(_factory, null); + helper.SyncCssVariables(widget); + helper.SyncJsVariables(widget); + + ReseatWidgetCard(widget); + + if (_route != null) OverlayEvents.RaiseOverlayRefreshRequested(_route.Id); + RefreshWebView(); + if (_selectedWidget?.Id == widget.Id) PopulateWidgetEditor(widget); + + UiHelpers.RevealInFileManager(loosePath); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to unpack widget {Name}", widget.Name); + ShowMessageFlyout(anchor, "Unpack Failed", ex.Message); + } } private async void CopyWidget_Click(object? sender, RoutedEventArgs e) diff --git a/SubathonManager.UI/EditRouteWindow.axaml b/SubathonManager.UI/EditRouteWindow.axaml index acc261d4..c56e80e1 100644 --- a/SubathonManager.UI/EditRouteWindow.axaml +++ b/SubathonManager.UI/EditRouteWindow.axaml @@ -24,6 +24,10 @@ + + + + @@ -308,13 +312,38 @@ + + + + + + + + + + + + + + + + + + + + - + + diff --git a/SubathonManager.UI/EditRouteWindow.axaml.cs b/SubathonManager.UI/EditRouteWindow.axaml.cs index bf971c41..2f772236 100644 --- a/SubathonManager.UI/EditRouteWindow.axaml.cs +++ b/SubathonManager.UI/EditRouteWindow.axaml.cs @@ -104,7 +104,11 @@ public EditRouteWindow(Guid routeId) Loaded += EditRouteWindow_Loaded; ObsConnected = ServiceManager.OBS.Connected; IntegrationEvents.ConnectionUpdated += OnObsConnectionUpdated; - Closed += (_, _) => IntegrationEvents.ConnectionUpdated -= OnObsConnectionUpdated; + Closed += (_, _) => + { + IntegrationEvents.ConnectionUpdated -= OnObsConnectionUpdated; + WidgetEvents.WidgetActionRequested -= OnWidgetActionRequested; + }; _cssLivePreviewTimer = new Timer(_ => { @@ -147,6 +151,7 @@ private async void EditRouteWindow_Loaded(object? sender, RoutedEventArgs e) WidgetEvents.WidgetScaleUpdated += OnWidgetScaleUpdated; WidgetEvents.WidgetSizeUpdated += OnWidgetSizeUpdated; WidgetEvents.SelectEditorWidget += SelectWidgetFromEvent; + WidgetEvents.WidgetActionRequested += OnWidgetActionRequested; } } @@ -498,15 +503,31 @@ private void RefreshWebView() catch { /**/ } } - public async Task AddWidgetPackAsync(string smwPath) + private async void BrowseWidgetsButton_Click(object? sender, RoutedEventArgs e) { if (_route == null) return; + var dialog = new WidgetBrowserDialog(async entry => + { + string file = WidgetCatalog.ToAbsolutePath(entry.PackPath); + return File.Exists(file) && await AddWidgetPackAsync(file); + }); + + await dialog.ShowDialog(this); + } + + public async Task AddWidgetPackAsync(string packPath) + { + if (_route == null) return false; + try { await using var db = await _factory.CreateDbContextAsync(); var helper = new WidgetEntityHelper(_factory, null); - await ImportWidgetPackAsync(smwPath, db, helper); + + bool added = packPath.EndsWith(WidgetCollectionInstaller.CollectionExtension, StringComparison.OrdinalIgnoreCase) + ? await ImportWidgetCollectionAsync(packPath, db, helper) + : await ImportWidgetPackAsync(packPath, db, helper); foreach (var existing in _widgets.ToList()) ReseatWidgetCard(existing); @@ -514,24 +535,49 @@ public async Task AddWidgetPackAsync(string smwPath) await RefreshWidgetZIndicesAsync(); OverlayEvents.RaiseOverlayRefreshRequested(_route.Id); RefreshWebView(); + + return added; } catch (Exception ex) { - _logger?.LogError(ex, "Failed to add widget package {Path}", smwPath); + _logger?.LogError(ex, "Failed to add widget package {Path}", packPath); + return false; } } - private async Task ImportWidgetPackAsync(string smwPath, AppDbContext db, WidgetEntityHelper helper) + private async Task ImportWidgetCollectionAsync(string smwcPath, AppDbContext db, WidgetEntityHelper helper) + { + var collection = WidgetCollectionInstaller.InstallAll(smwcPath); + if (collection == null) + { + _logger?.LogError("Could not read widget collection {Path}", smwcPath); + return false; + } + + if (collection.Failed > 0) + _logger?.LogWarning("Skipped {Count} unreadable package(s) in {Path}", collection.Failed, smwcPath); + + foreach (var pack in collection.Packs) + { + var manifest = pack.Manifest; + await ImportSingleWidgetAsync(pack.HtmlPath, db, helper, manifest.Name, manifest.ScaleX, manifest.ScaleY); + } + + return collection.Packs.Count > 0; + } + + private async Task ImportWidgetPackAsync(string smwPath, AppDbContext db, WidgetEntityHelper helper) { var installed = WidgetPackInstaller.Install(smwPath); if (installed == null) { _logger?.LogError("Could not read widget package {Path}", smwPath); - return; + return false; } var manifest = installed.Manifest; await ImportSingleWidgetAsync(installed.HtmlPath, db, helper, manifest.Name, manifest.ScaleX, manifest.ScaleY); + return true; } private async Task ImportSingleWidgetAsync(string path, AppDbContext db, WidgetEntityHelper helper, diff --git a/SubathonManager.UI/MainWindow.Routes.cs b/SubathonManager.UI/MainWindow.Routes.cs index 4e878de7..0ab4e98b 100644 --- a/SubathonManager.UI/MainWindow.Routes.cs +++ b/SubathonManager.UI/MainWindow.Routes.cs @@ -12,6 +12,8 @@ using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Data; +using SubathonManager.Data.Overlays; +using SubathonManager.Data.Widgets; using SubathonManager.UI.UiUtils; using SubathonManager.UI.Views; @@ -57,6 +59,18 @@ private void OpenBrowsePresets_Click(object? sender, RoutedEventArgs e) { }); } + private async void BrowseWidgets_Click(object? sender, RoutedEventArgs e) + { + try + { + await new WidgetBrowserDialog().ShowDialog(this); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to open the widget browser"); + } + } + private void OpenPresets_Click(object? sender, RoutedEventArgs e) => OpenRelativeFolder("presets"); private void OpenImports_Click(object? sender, RoutedEventArgs e) => OpenRelativeFolder("imports"); private void OpenExports_Click(object? sender, RoutedEventArgs e) => OpenRelativeFolder("exports"); @@ -213,10 +227,24 @@ private async Task ImportRouteFromFile(string filePath) filePath = tempFile; } - string importsDir = Path.GetFullPath("./imports"); try { - var result = await OverlayPorter.ImportRouteAsync(filePath, importsDir, _factory); + var installed = OverlayPackInstaller.Install(filePath); + if (installed == null) + { + _logger?.LogError("Import failed: could not read overlay archive {Path}", filePath); + return false; + } + + WidgetPackPaths.InvalidateResolveCache(); + WidgetPackPaths.InvalidateVersionCache(); + + var manifest = installed.Manifest; + var result = await OverlayPorter.ImportExtractedRouteAsync( + installed.UnpackDir, + _factory, + manifest.Name, + OverlayPackPaths.RouteName(manifest.Name, manifest.Version)); if (result.Failed) { @@ -244,7 +272,29 @@ private async Task ImportRouteFromFile(string filePath) if (result.NewJsVariables.Count > 0) db.JsVariables.AddRange(result.NewJsVariables); + foreach (var repointed in result.RepointedWidgets) + { + var tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == repointed.Id); + tracked?.HtmlPath = repointed.HtmlPath; + } + + if (result is { MergedRouteId: { } mergedId, MergedRouteName: { } mergedName }) + { + var existing = await db.Routes.FirstOrDefaultAsync(r => r.Id == mergedId); + existing?.Name = mergedName; + } + await db.SaveChangesAsync(); + + if (result.RepointedWidgets.Count > 0) + { + var helper = new WidgetEntityHelper(_factory, null); + foreach (var repointed in result.RepointedWidgets) + { + helper.SyncCssVariables(repointed); + helper.SyncJsVariables(repointed); + } + } await Dispatcher.UIThread.InvokeAsync(LoadRoutes); return true; } diff --git a/SubathonManager.UI/MainWindow.axaml b/SubathonManager.UI/MainWindow.axaml index 5c1d42b8..466382f5 100644 --- a/SubathonManager.UI/MainWindow.axaml +++ b/SubathonManager.UI/MainWindow.axaml @@ -230,6 +230,15 @@ + + + @@ -306,26 +315,57 @@ - - - + + + + + + + + + + + + diff --git a/SubathonManager.UI/MainWindow.axaml.cs b/SubathonManager.UI/MainWindow.axaml.cs index a25334f1..692db51f 100644 --- a/SubathonManager.UI/MainWindow.axaml.cs +++ b/SubathonManager.UI/MainWindow.axaml.cs @@ -71,20 +71,25 @@ private void CollectPendingWidgetPack() Utils.PendingWidgetPackImportPath = null; if (string.IsNullOrWhiteSpace(path)) return; - var installed = WidgetPackInstaller.DropIntoImports(path); - if (installed == null) + var editor = FindOpenOverlayEditor(); + if (editor != null) { - _logger?.LogWarning("Failed to collect widget package {Path}", path); + editor.Activate(); + _ = editor.AddWidgetPackAsync(path); return; } - _logger?.LogInformation("Collected widget package into {Path}", installed); + bool isCollection = path.EndsWith(WidgetCollectionInstaller.CollectionExtension, + StringComparison.OrdinalIgnoreCase); - var editor = FindOpenOverlayEditor(); - if (editor == null) return; + string? installed = isCollection + ? (WidgetCollectionInstaller.InstallAll(path) != null ? path : null) + : WidgetPackInstaller.DropIntoImports(path); - editor.Activate(); - _ = editor.AddWidgetPackAsync(installed); + if (installed == null) + _logger?.LogWarning("Failed to collect widget package {Path}", path); + else + _logger?.LogDebug("Collected widget package {Path}", installed); } private static EditRouteWindow? FindOpenOverlayEditor() diff --git a/SubathonManager.UI/Platform/PosixPlatformIntegration.cs b/SubathonManager.UI/Platform/PosixPlatformIntegration.cs index 555c3fe8..4701a69e 100644 --- a/SubathonManager.UI/Platform/PosixPlatformIntegration.cs +++ b/SubathonManager.UI/Platform/PosixPlatformIntegration.cs @@ -8,6 +8,7 @@ public sealed class PosixPlatformIntegration : PlatformIntegrationBase private const string SchemeMime = "x-scheme-handler/subathonmanager"; private const string OverlayMime = "application/x-subathonmanager-overlay"; private const string WidgetMime = "application/x-subathonmanager-widget"; + private const string CollectionMime = "application/x-subathonmanager-widget-collection"; private const string DesktopFileName = "subathonmanager.desktop"; private const string MimePackageFileName = "subathonmanager-overlay.xml"; private const string IconName = "subathonmanager"; @@ -67,7 +68,7 @@ private static void RegisterLinux() "NoDisplay=false\n" + "Categories=Utility;\n" + "StartupWMClass=SubathonManager\n" + - $"MimeType={OverlayMime};{WidgetMime};{SchemeMime};\n"; + $"MimeType={OverlayMime};{WidgetMime};{CollectionMime};{SchemeMime};\n"; string mimeContent = "\n" + @@ -82,6 +83,11 @@ private static void RegisterLinux() $" \n" + " \n" + " \n" + + $" \n" + + " Subathon Manager Widget Collection\n" + + $" \n" + + " \n" + + " \n" + "\n"; bool desktopChanged = WriteIfChanged(desktopPath, desktopContent); @@ -98,6 +104,7 @@ private static void RegisterLinux() Run("xdg-mime", "default", DesktopFileName, SchemeMime); Run("xdg-mime", "default", DesktopFileName, OverlayMime); Run("xdg-mime", "default", DesktopFileName, WidgetMime); + Run("xdg-mime", "default", DesktopFileName, CollectionMime); } private static bool InstallHicolorIcons(string appDir, string dataHome) diff --git a/SubathonManager.UI/Platform/ProtocolParser.cs b/SubathonManager.UI/Platform/ProtocolParser.cs index 6496a91e..3528034b 100644 --- a/SubathonManager.UI/Platform/ProtocolParser.cs +++ b/SubathonManager.UI/Platform/ProtocolParser.cs @@ -21,7 +21,8 @@ public static ActivationRequest Parse(string[] args) return new ActivationRequest(ActivationKind.SmoFile, arg); } - if (arg.EndsWith(".smw", StringComparison.OrdinalIgnoreCase) && File.Exists(arg)) + if ((arg.EndsWith(".smw", StringComparison.OrdinalIgnoreCase) || + arg.EndsWith(".smwc", StringComparison.OrdinalIgnoreCase)) && File.Exists(arg)) { Utils.PendingWidgetPackImportPath = arg; return new ActivationRequest(ActivationKind.SmwFile, arg); diff --git a/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs b/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs index a77f0fc7..f60f5e5e 100644 --- a/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs +++ b/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs @@ -30,6 +30,12 @@ public override void RegisterFileAssociations() EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Widget\DefaultIcon", "", $"{exePath},0"); EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Widget\shell\open\command", "", $"\"{exePath}\" \"%1\""); EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smw\OpenWithProgids", "SubathonManager.Widget", ""); + + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smwc", "", "SubathonManager.WidgetCollection"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.WidgetCollection", "", "Subathon Manager Widget Collection"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.WidgetCollection\DefaultIcon", "", $"{exePath},0"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.WidgetCollection\shell\open\command", "", $"\"{exePath}\" \"%1\""); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smwc\OpenWithProgids", "SubathonManager.WidgetCollection", ""); } private static void EnsureRegistryValue(string keyPath, string name, string expectedValue) diff --git a/SubathonManager.UI/Views/ExportOverlayDialog.axaml b/SubathonManager.UI/Views/ExportOverlayDialog.axaml index d3093ad4..39113779 100644 --- a/SubathonManager.UI/Views/ExportOverlayDialog.axaml +++ b/SubathonManager.UI/Views/ExportOverlayDialog.axaml @@ -6,22 +6,17 @@ mc:Ignorable="d" x:Class="SubathonManager.UI.Views.ExportOverlayDialog" Title="Export Overlay" - Width="560" Height="580" + Width="560" Height="640" CanResize="False" WindowStartupLocation="CenterOwner" Background="{DynamicResource AppGeneralBackground}"> - + - + - - - - - + + + + + + + + + + + + + + + + - - @@ -58,7 +71,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +