From 15ca31f97d817e6ee6688192936030fdfcf5c42d Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:09:06 -0300 Subject: [PATCH 1/8] fix youtube membership tier adding --- .../Views/SettingsViews/Streaming/YouTubeSettings.xaml.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.xaml.cs index 2ff3ea92..58103b7d 100644 --- a/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.xaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.xaml.cs @@ -17,6 +17,7 @@ namespace SubathonManager.UI.Views.SettingsViews.Streaming; public partial class YouTubeSettings : SettingsControl { protected override StackPanel? _MembershipsPanel => MembershipsPanel; + protected override SubathonEventType? _membershipEventType => SubathonEventType.YouTubeMembership; public YouTubeSettings() { From 070740c912f5c0791cffc623c90bef1f2cd1df5d Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:40:17 -0300 Subject: [PATCH 2/8] fix adding external custom subs --- .../Views/SettingsViews/External/ExternalServiceSettings.xaml.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.xaml.cs b/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.xaml.cs index 14d2da22..263e9f83 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.xaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.xaml.cs @@ -17,6 +17,7 @@ public partial class ExternalServiceSettings : SettingsControl { private readonly ILogger? _logger = AppServices.Provider.GetRequiredService>(); protected override StackPanel? _MembershipsPanel => MembershipsPanel; + protected override SubathonEventType? _membershipEventType => SubathonEventType.ExternalSub; public ExternalServiceSettings() { From 203cae54c36b7ce49d54082558c5398ae271138c Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:35:35 -0300 Subject: [PATCH 3/8] add subscriptiontotals datatype --- SubathonManager.Core/Events/SubathonEvents.cs | 8 +- .../Objects/SubscriptionTotals.cs | 20 ++++ SubathonManager.Server/WebServer.WebSocket.cs | 35 +++++++ SubathonManager.Services/EventService.cs | 94 ++++++++++++++++--- 4 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 SubathonManager.Core/Objects/SubscriptionTotals.cs diff --git a/SubathonManager.Core/Events/SubathonEvents.cs b/SubathonManager.Core/Events/SubathonEvents.cs index 6bcfca0d..2b494f33 100644 --- a/SubathonManager.Core/Events/SubathonEvents.cs +++ b/SubathonManager.Core/Events/SubathonEvents.cs @@ -22,7 +22,8 @@ public static class SubathonEvents public static event Action>? SubathonValuesPatched; public static event Action? SubathonTotalsUpdated; - + public static event Action? SubscriptionTotalsUpdated; + public static event Action? PromptRunStarted; public static event Action? PromptRunUpdate; public static event Action? PromptRunProgressUpdated; @@ -34,6 +35,11 @@ public static void RaiseSubathonTotalsUpdated(SubathonTotals totals) SubathonTotalsUpdated?.Invoke(totals); } + public static void RaiseSubscriptionTotalsUpdated(SubscriptionTotals totals) + { + SubscriptionTotalsUpdated?.Invoke(totals); + } + public static void RaisePromptRunStarted(SubathonPromptRun run, SubathonPrompt? prompt) => PromptRunStarted?.Invoke(run, prompt); diff --git a/SubathonManager.Core/Objects/SubscriptionTotals.cs b/SubathonManager.Core/Objects/SubscriptionTotals.cs new file mode 100644 index 00000000..2d21ae14 --- /dev/null +++ b/SubathonManager.Core/Objects/SubscriptionTotals.cs @@ -0,0 +1,20 @@ +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core.Objects; + +public class SubscriptionTotals +{ + public int SubTotal { get; init; } = 0; + public Dictionary SubTotalByEvent { get; init; } = new(); + + public Dictionary> SubTotalByEventTier { get; init; } = new(); + + public SubscriptionSimulatedTotals Simulated { get; init; } = new(); +} + +public class SubscriptionSimulatedTotals +{ + public int SubTotal { get; init; } = 0; + public Dictionary SubTotalByEvent { get; init; } = new(); + public Dictionary> SubTotalByEventTier { get; init; } = new(); +} diff --git a/SubathonManager.Server/WebServer.WebSocket.cs b/SubathonManager.Server/WebServer.WebSocket.cs index 0e401fd6..731e88ba 100644 --- a/SubathonManager.Server/WebServer.WebSocket.cs +++ b/SubathonManager.Server/WebServer.WebSocket.cs @@ -30,6 +30,7 @@ private void SetupWebsocketListeners() OverlayEvents.OverlayRefreshRequested += SendRefreshRequest; SubathonEvents.SubathonValueConfigRequested += SendSubathonValues; SubathonEvents.SubathonTotalsUpdated += SendSubathonTotals; + SubathonEvents.SubscriptionTotalsUpdated += SendSubscriptionTotals; SubathonEvents.PromptRunStarted += OnPromptStart; SubathonEvents.PromptRunUpdate += OnPromptRunUpdate; @@ -53,6 +54,7 @@ private void StopWebsocketServer() SubathonEvents.SubathonGoalListUpdated -= SendGoalsUpdated; SubathonEvents.SubathonValueConfigRequested -= SendSubathonValues; SubathonEvents.SubathonTotalsUpdated -= SendSubathonTotals; + SubathonEvents.SubscriptionTotalsUpdated -= SendSubscriptionTotals; SubathonEvents.PromptRunStarted -= OnPromptStart; SubathonEvents.PromptRunUpdate -= OnPromptRunUpdate; @@ -220,6 +222,11 @@ private async Task InitConnection(IWebSocketClient socket) if (totals != null) await SelectSendAsync(socket, SubathonTotalsToObject(totals)); + var subTotals = await EventService.GetSubscriptionTotalsAsync(db); + + if (subTotals != null) + await SelectSendAsync(socket, SubscriptionTotalsToObject(subTotals)); + var activeWheel = await db.WheelSets .Include(w => w.WheelItems) .ThenInclude(i => i.Action) @@ -278,6 +285,27 @@ private object SubathonTotalsToObject(SubathonTotals totals) }; } + private object SubscriptionTotalsToObject(SubscriptionTotals totals) + { + return new + { + type = "subscription_totals", + sub_total = totals.SubTotal, + sub_total_by_type = totals.SubTotalByEvent + .ToDictionary(k => k.Key.ToString(), k => k.Value), + sub_total_by_type_tier = totals.SubTotalByEventTier + .ToDictionary(k => k.Key.ToString(), k => k.Value), + simulated = new + { + sub_total = totals.Simulated.SubTotal, + sub_total_by_type = totals.Simulated.SubTotalByEvent + .ToDictionary(k => k.Key.ToString(), k => k.Value), + sub_total_by_type_tier = totals.Simulated.SubTotalByEventTier + .ToDictionary(k => k.Key.ToString(), k => k.Value), + } + }; + } + private object SubathonEventToObject(SubathonEvent subathonEvent) { var trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); @@ -322,6 +350,11 @@ internal void SendSubathonTotals(SubathonTotals totals) Task.Run(() => BroadcastAsyncObject(SubathonTotalsToObject(totals), WebsocketClientTypeHelper.ConsumersList)); } + internal void SendSubscriptionTotals(SubscriptionTotals totals) + { + Task.Run(() => BroadcastAsyncObject(SubscriptionTotalsToObject(totals), WebsocketClientTypeHelper.ConsumersList)); + } + internal void SendRefreshRequest(Guid id) { Task.Run(() => @@ -809,6 +842,8 @@ function connect() {{ window.handleValueConfig(data); else if (typeof window.handleTotalsUpdate === 'function' && data.type == 'subathon_totals') window.handleTotalsUpdate(data); + else if (typeof window.handleSubscriptionTotalsUpdate === 'function' && data.type == 'subscription_totals') + window.handleSubscriptionTotalsUpdate(data); else if (data.type == 'refresh_request' && document.title.startsWith('overlay') && (document.title.includes(data.id) || data.id == '{Guid.Empty}')) {{ // for only the merged page window.location.reload(); diff --git a/SubathonManager.Services/EventService.cs b/SubathonManager.Services/EventService.cs index 6a8b2ec2..45e02cef 100644 --- a/SubathonManager.Services/EventService.cs +++ b/SubathonManager.Services/EventService.cs @@ -379,11 +379,7 @@ private async Task LoopAsync() db.Entry(subathon).State = EntityState.Detached; if (affected > 0 || (ev.ProcessedToSubathon)) - { - var totals = await GetSubathonTotalsAsync(db); - if (totals != null) - SubathonEvents.RaiseSubathonTotalsUpdated(totals); - } + await RaiseAllTotalsUpdatedAsync(db); return (affected > 0 || (ev.ProcessedToSubathon), false); } @@ -720,10 +716,8 @@ public async Task DeleteSubathonEvent(AppDbContext db, SubathonEvent ev) events.Add(ev); SubathonEvents.RaiseSubathonEventsDeleted(events); - var totals = await GetSubathonTotalsAsync(db); - if (totals != null) - SubathonEvents.RaiseSubathonTotalsUpdated(totals); - + await RaiseAllTotalsUpdatedAsync(db); + if (affected > 0) { await db.Entry(subathon!).ReloadAsync(); @@ -852,9 +846,7 @@ public async Task UndoSimulatedEvents(AppDbContext db, List event } finally { - var totals = await GetSubathonTotalsAsync(db); - if (totals != null) - SubathonEvents.RaiseSubathonTotalsUpdated(totals); + await RaiseAllTotalsUpdatedAsync(db); } } @@ -1011,6 +1003,84 @@ static SubathonSimulatedTotals BuildSimulated(List src) return totals; } + private static string GetSubTierKey(EventProjection e) + { + var meta = !string.IsNullOrWhiteSpace(e.EventTypeMeta) ? e.EventTypeMeta + : string.IsNullOrWhiteSpace(e.Value) ? "DEFAULT" : e.Value; + return e.EventType == null ? meta : e.EventType.Value.TierMetaDisplayName(meta); + } + + public static async Task GetSubscriptionTotalsAsync(AppDbContext db) + { + var subathon = await db.SubathonDatas + .AsNoTracking() + .Select(x => new { x.Id, x.IsActive }) + .FirstOrDefaultAsync(x => x.IsActive); + + if (subathon == null) return new SubscriptionTotals(); + + var subEventTypes = SubathonEventSubTypeHelper.SubEventTypes + .Select(t => (SubathonEventType?)t) + .ToList(); + + var allEvents = await db.SubathonEvents + .AsNoTracking() + .Where(e => + e.SubathonId == subathon.Id && + e.ProcessedToSubathon && + subEventTypes.Contains(e.EventType)) + .Select(e => new EventProjection( + e.EventType, + e.Source, + e.Amount, + e.Value, + e.Source == SubathonEventSource.Simulated || + e.User!.StartsWith("SIMULATED") || + e.User!.StartsWith("SYSTEM"), + e.EventTypeMeta + )) + .ToListAsync(); + + var events = allEvents.Where(e => !e.IsSimulated).ToList(); + var simEvents = allEvents.Where(e => e.IsSimulated).ToList(); + + static Dictionary ByEvent(List src) => src + .GroupBy(e => e.EventType!.Value) + .ToDictionary(g => g.Key, g => g.Sum(e => e.Amount)); + + static Dictionary> ByEventTier(List src) => src + .GroupBy(e => e.EventType!.Value) + .ToDictionary( + g => g.Key, + g => g.GroupBy(GetSubTierKey) + .ToDictionary(t => t.Key, t => t.Sum(e => e.Amount))); + + return new SubscriptionTotals + { + SubTotal = events.Sum(e => e.Amount), + SubTotalByEvent = ByEvent(events), + SubTotalByEventTier = ByEventTier(events), + + Simulated = new SubscriptionSimulatedTotals + { + SubTotal = simEvents.Sum(e => e.Amount), + SubTotalByEvent = ByEvent(simEvents), + SubTotalByEventTier = ByEventTier(simEvents) + } + }; + } + + public static async Task RaiseAllTotalsUpdatedAsync(AppDbContext db) + { + var totals = await GetSubathonTotalsAsync(db); + if (totals != null) + SubathonEvents.RaiseSubathonTotalsUpdated(totals); + + var subTotals = await GetSubscriptionTotalsAsync(db); + if (subTotals != null) + SubathonEvents.RaiseSubscriptionTotalsUpdated(subTotals); + } + public async Task StopAsync(CancellationToken ct = default) { if (!_cts.IsCancellationRequested) From ee8bee7426bf0a35304877f4ba42d0ed299afbb2 Mon Sep 17 00:00:00 2001 From: WolfwithSword Date: Tue, 28 Jul 2026 21:28:31 -0300 Subject: [PATCH 4/8] Port UI to Avalonia (#305) * initial avalonia port attempt full send * migrate webserver to kestrel for more reliable xplat stuff * fix webserver, optimize db contexts * fix blerp unit tests * fix linux build * add adjustable build targets * fix build singles * better shutdown behaviour * modify dispatch to wiki * fix wiki workflow * attempt fix linux webview * fix linux sys theme * try fix theme * test webkitgtk for linux * fallback for linux browser previewer * try gtk fix * undo but keep the weird experimental offscreen bit * attempt fix webview linux * add new overlay tab buttons * fix scaling and dragging in webview * retest update in place launch behaviour * update build * prompt telemetry on new install * migrate :> * prettify build output maybe * fix output * try fix nc upload * try fix nc upload * fix telemetry optout and adjust macos warning for web preview * attempt mac scaling fix * make other icons for linux --- .github/workflows/build.yml | 646 +++++-- .github/workflows/wiki-drawio-upload.yml | 27 +- .gitignore | 3 + SubathonManager.Core/AppServices.cs | 25 +- .../Enums/SubathonPromptType.cs | 4 + SubathonManager.Core/Objects/WidgetMeta.cs | 4 +- .../ExternalEventService.cs | 12 +- SubathonManager.Integration/TwitchService.cs | 3 +- SubathonManager.Integration/YouTubeService.cs | 3 +- .../AspNetContextAdapter.cs | 64 + .../HttpListenerContextAdapter.cs | 83 - .../SubathonManager.Server.csproj | 4 + .../TcpListenerContextAdapter.cs | 218 --- SubathonManager.Server/WebServer.Overlays.cs | 334 ++-- SubathonManager.Server/WebServer.WebSocket.cs | 8 +- SubathonManager.Server/WebServer.cs | 164 +- .../YouTubeServiceTests.cs | 39 + SubathonManager.UI/App.Subathon.cs | 234 +++ SubathonManager.UI/App.Subathon.xaml.cs | 118 -- SubathonManager.UI/App.axaml | 43 + SubathonManager.UI/App.axaml.cs | 481 +++++ SubathonManager.UI/App.xaml | 15 - SubathonManager.UI/App.xaml.cs | 738 ------- SubathonManager.UI/AssemblyInfo.cs | 13 - SubathonManager.UI/Assets/icon.png | Bin 0 -> 1100319 bytes SubathonManager.UI/Assets/icon_base.png | Bin 0 -> 1086911 bytes SubathonManager.UI/Controls/SymIcon.cs | 38 + SubathonManager.UI/Converters/Converters.cs | 576 +++--- .../EditRouteWindow.Handlers.cs | 819 +++----- SubathonManager.UI/EditRouteWindow.axaml | 486 +++++ ...indow.xaml.cs => EditRouteWindow.axaml.cs} | 518 ++--- SubathonManager.UI/EditRouteWindow.xaml | 768 -------- SubathonManager.UI/MainWindow.Cap.cs | 49 +- .../MainWindow.ConnectionStatus.cs | 547 +++--- SubathonManager.UI/MainWindow.Multiplier.cs | 113 +- SubathonManager.UI/MainWindow.Obs.cs | 46 +- SubathonManager.UI/MainWindow.Routes.cs | 386 ++-- SubathonManager.UI/MainWindow.Stats.cs | 64 +- .../MainWindow.SubathonTimer.cs | 583 +++--- SubathonManager.UI/MainWindow.Telemetry.cs | 56 + .../MainWindow.Validation.xaml.cs | 32 - SubathonManager.UI/MainWindow.axaml | 365 ++++ SubathonManager.UI/MainWindow.axaml.cs | 56 + SubathonManager.UI/MainWindow.xaml | 666 ------- SubathonManager.UI/MainWindow.xaml.cs | 336 ---- .../Platform/IPlatformIntegration.cs | 18 + .../Platform/PlatformIntegrationBase.cs | 96 + .../Platform/PosixPlatformIntegration.cs | 172 ++ SubathonManager.UI/Platform/ProtocolParser.cs | 59 + .../Platform/WindowsPlatformIntegration.cs | 36 + SubathonManager.UI/Program.cs | 49 + .../Services/ServiceRegistration.cs | 2 +- SubathonManager.UI/SubathonManager.UI.csproj | 162 +- SubathonManager.UI/Themes/ControlTweaks.axaml | 76 + SubathonManager.UI/Themes/DARK.xaml | 22 - SubathonManager.UI/Themes/LIGHT.xaml | 22 - SubathonManager.UI/Themes/TabStyles.axaml | 121 ++ SubathonManager.UI/Themes/ThemeBrushes.axaml | 83 + .../UiUtils/AutoCompleteBoxBehaviours.cs | 47 + SubathonManager.UI/UiUtils/ClickDragSlider.cs | 36 - .../UiUtils/ControlExtensions.cs | 67 + .../UiUtils/CssVariableTemplateSelector.cs | 28 +- SubathonManager.UI/UiUtils/EventTypeMenu.cs | 81 +- .../UiUtils/JSVariableTemplateSelector.cs | 51 +- SubathonManager.UI/UiUtils/TextBoxAssist.cs | 100 + SubathonManager.UI/UiUtils/UiHelpers.cs | 119 ++ SubathonManager.UI/UiUtils/UiUtils.cs | 106 - .../Validation/InputValidationBehavior.cs | 93 - .../Validation/NumericInputBehaviour.cs | 83 + SubathonManager.UI/Views/CssColorPicker.axaml | 23 + .../Views/CssColorPicker.axaml.cs | 121 ++ SubathonManager.UI/Views/CssColorPicker.xaml | 235 --- .../Views/CssColorPicker.xaml.cs | 359 ---- SubathonManager.UI/Views/EventListView.axaml | 162 ++ .../Views/EventListView.axaml.cs | 130 ++ SubathonManager.UI/Views/EventListView.xaml | 246 --- .../Views/EventListView.xaml.cs | 134 -- .../Views/ExportOverlayDialog.axaml | 67 + ...g.xaml.cs => ExportOverlayDialog.axaml.cs} | 143 +- .../Views/ExportOverlayDialog.xaml | 106 - SubathonManager.UI/Views/GoalsEditor.axaml | 78 + SubathonManager.UI/Views/GoalsEditor.axaml.cs | 561 ++++++ SubathonManager.UI/Views/GoalsEditor.xaml | 112 -- SubathonManager.UI/Views/GoalsEditor.xaml.cs | 555 ------ SubathonManager.UI/Views/GoalsView.axaml | 31 + .../{GoalsView.xaml.cs => GoalsView.axaml.cs} | 68 +- SubathonManager.UI/Views/GoalsView.xaml | 64 - .../Views/ObsAddSourceDialog.axaml | 36 + ...og.xaml.cs => ObsAddSourceDialog.axaml.cs} | 24 +- .../Views/ObsAddSourceDialog.xaml | 54 - .../Views/ObsBrowserSourcesPanel.axaml | 25 + ...aml.cs => ObsBrowserSourcesPanel.axaml.cs} | 183 +- .../Views/ObsBrowserSourcesPanel.xaml | 33 - .../Views/Prompts/PromptRunListView.axaml | 66 + ...iew.xaml.cs => PromptRunListView.axaml.cs} | 62 +- .../Views/Prompts/PromptRunListView.xaml | 109 -- .../Views/Prompts/PromptsEditor.axaml | 186 ++ .../Views/Prompts/PromptsEditor.axaml.cs | 1270 ++++++++++++ .../Views/Prompts/PromptsEditor.xaml | 296 --- .../Views/Prompts/PromptsEditor.xaml.cs | 1224 ------------ SubathonManager.UI/Views/SettingsControl.cs | 169 +- .../Views/SettingsProperties.cs | 18 +- .../Views/SettingsView.Settings.cs | 435 ++--- .../Views/SettingsView.SubathonTimer.cs | 164 +- SubathonManager.UI/Views/SettingsView.axaml | 117 ++ .../Views/SettingsView.axaml.cs | 205 ++ SubathonManager.UI/Views/SettingsView.xaml | 205 -- SubathonManager.UI/Views/SettingsView.xaml.cs | 263 --- .../SettingsViews/CommandsSettings.axaml | 22 + ...ings.xaml.cs => CommandsSettings.axaml.cs} | 108 +- .../Views/SettingsViews/CommandsSettings.xaml | 34 - .../Components/TrackedValueRowsControl.cs | 83 +- .../SettingsViews/ExtensionSettings.axaml | 21 + ...ngs.xaml.cs => ExtensionSettings.axaml.cs} | 9 +- .../SettingsViews/ExtensionSettings.xaml | 29 - .../Extensions/ChatExtensionSettings.axaml | 52 + ...xaml.cs => ChatExtensionSettings.axaml.cs} | 70 +- .../Extensions/ChatExtensionSettings.xaml | 87 - .../Extensions/StreamElementsSettings.axaml | 40 + ...aml.cs => StreamElementsSettings.axaml.cs} | 54 +- .../Extensions/StreamElementsSettings.xaml | 66 - .../Extensions/StreamLabsSettings.axaml | 40 + ...gs.xaml.cs => StreamLabsSettings.axaml.cs} | 47 +- .../Extensions/StreamLabsSettings.xaml | 67 - .../Extensions/TangiaSettings.axaml | 38 + ...ttings.xaml.cs => TangiaSettings.axaml.cs} | 96 +- .../Extensions/TangiaSettings.xaml | 71 - .../Extensions/TipeeeStreamSettings.axaml | 39 + ....xaml.cs => TipeeeStreamSettings.axaml.cs} | 23 +- .../Extensions/TipeeeStreamSettings.xaml | 73 - .../Extensions/TreatStreamSettings.axaml | 36 + ...s.xaml.cs => TreatStreamSettings.axaml.cs} | 23 +- .../Extensions/TreatStreamSettings.xaml | 64 - .../External/DevTunnelSettingsControl.cs | 78 +- .../External/DevTunnelsSettings.xaml | 128 -- .../External/ExternalServiceSettings.axaml | 58 + ...ml.cs => ExternalServiceSettings.axaml.cs} | 142 +- .../External/ExternalServiceSettings.xaml | 94 - .../External/FourthWallSettings.axaml | 156 ++ ...gs.xaml.cs => FourthWallSettings.axaml.cs} | 301 ++- .../External/FourthWallSettings.xaml | 276 --- .../GoAffPro/GoAffProSourceControl.axaml | 44 + ...xaml.cs => GoAffProSourceControl.axaml.cs} | 41 +- .../GoAffPro/GoAffProSourceControl.xaml | 68 - .../External/GoAffProSettings.axaml | 31 + ...ings.xaml.cs => GoAffProSettings.axaml.cs} | 161 +- .../External/GoAffProSettings.xaml | 45 - .../External/JuniperSettings.axaml | 57 + ...tings.xaml.cs => JuniperSettings.axaml.cs} | 45 +- .../External/JuniperSettings.xaml | 65 - .../External/KoFi/KoFiSettings.axaml | 19 + ...Settings.xaml.cs => KoFiSettings.axaml.cs} | 18 +- .../External/KoFi/KoFiSettings.xaml | 24 - .../External/KoFi/KoFiWebhookSettings.axaml | 87 + ...s.xaml.cs => KoFiWebhookSettings.axaml.cs} | 48 +- .../External/KoFi/KoFiWebhookSettings.xaml | 110 -- .../External/KoFiCombinedSettings.axaml | 124 ++ ....xaml.cs => KoFiCombinedSettings.axaml.cs} | 74 +- .../External/KoFiCombinedSettings.xaml | 194 -- .../External/MakeShipSettings.axaml | 72 + ...ings.xaml.cs => MakeShipSettings.axaml.cs} | 61 +- .../External/MakeShipSettings.xaml | 82 - .../External/PallySettings.axaml | 64 + ...ettings.xaml.cs => PallySettings.axaml.cs} | 31 +- .../SettingsViews/External/PallySettings.xaml | 75 - .../External/ThroneSettings.axaml | 108 ++ ...ttings.xaml.cs => ThroneSettings.axaml.cs} | 112 +- .../External/ThroneSettings.xaml | 156 -- .../SettingsViews/ExternalSettings.axaml | 21 + ...ings.xaml.cs => ExternalSettings.axaml.cs} | 13 +- .../Views/SettingsViews/ExternalSettings.xaml | 29 - .../ExternalSoftware/DevTunnelsSettings.axaml | 141 ++ .../DevTunnelsSettings.axaml.cs} | 139 +- .../ExternalSoftware/ObsSettings.axaml | 53 + ...sSettings.xaml.cs => ObsSettings.axaml.cs} | 44 +- .../ExternalSoftware/ObsSettings.xaml | 50 - .../ExternalSoftware/StreamDeckSettings.axaml | 23 + ...gs.xaml.cs => StreamDeckSettings.axaml.cs} | 8 +- .../ExternalSoftware/StreamDeckSettings.xaml | 24 - .../StreamerBotSettings.axaml | 20 + ...s.xaml.cs => StreamerBotSettings.axaml.cs} | 6 +- .../ExternalSoftware/StreamerBotSettings.xaml | 21 - .../ExternalSoftwareSettings.axaml | 22 + ...l.cs => ExternalSoftwareSettings.axaml.cs} | 4 +- .../ExternalSoftwareSettings.xaml | 29 - .../SettingsViews/SettingsGroupControl.cs | 87 +- .../Streaming/PicartoSettings.axaml | 125 ++ ...tings.xaml.cs => PicartoSettings.axaml.cs} | 151 +- .../Streaming/PicartoSettings.xaml | 210 -- .../Streaming/TwitchSettings.Simulate.cs | 81 +- .../Streaming/TwitchSettings.axaml | 195 ++ ...ttings.xaml.cs => TwitchSettings.axaml.cs} | 162 +- .../Streaming/TwitchSettings.xaml | 342 ---- .../Streaming/YouTubeSettings.Simulate.cs | 32 +- .../Streaming/YouTubeSettings.axaml | 98 + ...tings.xaml.cs => YouTubeSettings.axaml.cs} | 118 +- .../Streaming/YouTubeSettings.xaml | 171 -- .../SettingsViews/StreamingSettings.axaml | 21 + ...ngs.xaml.cs => StreamingSettings.axaml.cs} | 11 +- .../SettingsViews/StreamingSettings.xaml | 29 - .../SettingsViews/WebhookLogSettings.axaml | 49 + ...gs.xaml.cs => WebhookLogSettings.axaml.cs} | 104 +- .../SettingsViews/WebhookLogSettings.xaml | 59 - .../Views/WheelSpin/WheelEditor.axaml | 245 +++ .../Views/WheelSpin/WheelEditor.axaml.cs | 1697 ++++++++++++++++ .../Views/WheelSpin/WheelEditor.xaml | 350 ---- .../Views/WheelSpin/WheelEditor.xaml.cs | 1707 ----------------- .../Views/WheelSpin/WheelTriggerEditor.axaml | 227 +++ .../WheelSpin/WheelTriggerEditor.axaml.cs | 1370 +++++++++++++ .../Views/WheelSpin/WheelTriggerEditor.xaml | 330 ---- .../WheelSpin/WheelTriggerEditor.xaml.cs | 1399 -------------- SubathonManager.UI/app.manifest | 17 + SubathonManager.sln | 182 +- external/linux/README.md | 34 - external/linux/install.sh | 108 +- external/macos/Info.plist | 81 + external/macos/README.txt | 58 + external/macos/make-app-bundle.sh | 49 + presets/popups/alert/event-alert.html | 1 - 219 files changed, 16220 insertions(+), 18723 deletions(-) create mode 100644 SubathonManager.Server/AspNetContextAdapter.cs delete mode 100644 SubathonManager.Server/HttpListenerContextAdapter.cs delete mode 100644 SubathonManager.Server/TcpListenerContextAdapter.cs create mode 100644 SubathonManager.UI/App.Subathon.cs delete mode 100644 SubathonManager.UI/App.Subathon.xaml.cs create mode 100644 SubathonManager.UI/App.axaml create mode 100644 SubathonManager.UI/App.axaml.cs delete mode 100644 SubathonManager.UI/App.xaml delete mode 100644 SubathonManager.UI/App.xaml.cs delete mode 100644 SubathonManager.UI/AssemblyInfo.cs create mode 100644 SubathonManager.UI/Assets/icon.png create mode 100644 SubathonManager.UI/Assets/icon_base.png create mode 100644 SubathonManager.UI/Controls/SymIcon.cs create mode 100644 SubathonManager.UI/EditRouteWindow.axaml rename SubathonManager.UI/{EditRouteWindow.xaml.cs => EditRouteWindow.axaml.cs} (55%) delete mode 100644 SubathonManager.UI/EditRouteWindow.xaml create mode 100644 SubathonManager.UI/MainWindow.Telemetry.cs delete mode 100644 SubathonManager.UI/MainWindow.Validation.xaml.cs create mode 100644 SubathonManager.UI/MainWindow.axaml create mode 100644 SubathonManager.UI/MainWindow.axaml.cs delete mode 100644 SubathonManager.UI/MainWindow.xaml delete mode 100644 SubathonManager.UI/MainWindow.xaml.cs create mode 100644 SubathonManager.UI/Platform/IPlatformIntegration.cs create mode 100644 SubathonManager.UI/Platform/PlatformIntegrationBase.cs create mode 100644 SubathonManager.UI/Platform/PosixPlatformIntegration.cs create mode 100644 SubathonManager.UI/Platform/ProtocolParser.cs create mode 100644 SubathonManager.UI/Platform/WindowsPlatformIntegration.cs create mode 100644 SubathonManager.UI/Program.cs create mode 100644 SubathonManager.UI/Themes/ControlTweaks.axaml delete mode 100644 SubathonManager.UI/Themes/DARK.xaml delete mode 100644 SubathonManager.UI/Themes/LIGHT.xaml create mode 100644 SubathonManager.UI/Themes/TabStyles.axaml create mode 100644 SubathonManager.UI/Themes/ThemeBrushes.axaml create mode 100644 SubathonManager.UI/UiUtils/AutoCompleteBoxBehaviours.cs delete mode 100644 SubathonManager.UI/UiUtils/ClickDragSlider.cs create mode 100644 SubathonManager.UI/UiUtils/ControlExtensions.cs create mode 100644 SubathonManager.UI/UiUtils/TextBoxAssist.cs create mode 100644 SubathonManager.UI/UiUtils/UiHelpers.cs delete mode 100644 SubathonManager.UI/UiUtils/UiUtils.cs delete mode 100644 SubathonManager.UI/Validation/InputValidationBehavior.cs create mode 100644 SubathonManager.UI/Validation/NumericInputBehaviour.cs create mode 100644 SubathonManager.UI/Views/CssColorPicker.axaml create mode 100644 SubathonManager.UI/Views/CssColorPicker.axaml.cs delete mode 100644 SubathonManager.UI/Views/CssColorPicker.xaml delete mode 100644 SubathonManager.UI/Views/CssColorPicker.xaml.cs create mode 100644 SubathonManager.UI/Views/EventListView.axaml create mode 100644 SubathonManager.UI/Views/EventListView.axaml.cs delete mode 100644 SubathonManager.UI/Views/EventListView.xaml delete mode 100644 SubathonManager.UI/Views/EventListView.xaml.cs create mode 100644 SubathonManager.UI/Views/ExportOverlayDialog.axaml rename SubathonManager.UI/Views/{ExportOverlayDialog.xaml.cs => ExportOverlayDialog.axaml.cs} (73%) delete mode 100644 SubathonManager.UI/Views/ExportOverlayDialog.xaml create mode 100644 SubathonManager.UI/Views/GoalsEditor.axaml create mode 100644 SubathonManager.UI/Views/GoalsEditor.axaml.cs delete mode 100644 SubathonManager.UI/Views/GoalsEditor.xaml delete mode 100644 SubathonManager.UI/Views/GoalsEditor.xaml.cs create mode 100644 SubathonManager.UI/Views/GoalsView.axaml rename SubathonManager.UI/Views/{GoalsView.xaml.cs => GoalsView.axaml.cs} (73%) delete mode 100644 SubathonManager.UI/Views/GoalsView.xaml create mode 100644 SubathonManager.UI/Views/ObsAddSourceDialog.axaml rename SubathonManager.UI/Views/{ObsAddSourceDialog.xaml.cs => ObsAddSourceDialog.axaml.cs} (72%) delete mode 100644 SubathonManager.UI/Views/ObsAddSourceDialog.xaml create mode 100644 SubathonManager.UI/Views/ObsBrowserSourcesPanel.axaml rename SubathonManager.UI/Views/{ObsBrowserSourcesPanel.xaml.cs => ObsBrowserSourcesPanel.axaml.cs} (62%) delete mode 100644 SubathonManager.UI/Views/ObsBrowserSourcesPanel.xaml create mode 100644 SubathonManager.UI/Views/Prompts/PromptRunListView.axaml rename SubathonManager.UI/Views/Prompts/{PromptRunListView.xaml.cs => PromptRunListView.axaml.cs} (84%) delete mode 100644 SubathonManager.UI/Views/Prompts/PromptRunListView.xaml create mode 100644 SubathonManager.UI/Views/Prompts/PromptsEditor.axaml create mode 100644 SubathonManager.UI/Views/Prompts/PromptsEditor.axaml.cs delete mode 100644 SubathonManager.UI/Views/Prompts/PromptsEditor.xaml delete mode 100644 SubathonManager.UI/Views/Prompts/PromptsEditor.xaml.cs create mode 100644 SubathonManager.UI/Views/SettingsView.axaml create mode 100644 SubathonManager.UI/Views/SettingsView.axaml.cs delete mode 100644 SubathonManager.UI/Views/SettingsView.xaml delete mode 100644 SubathonManager.UI/Views/SettingsView.xaml.cs create mode 100644 SubathonManager.UI/Views/SettingsViews/CommandsSettings.axaml rename SubathonManager.UI/Views/SettingsViews/{CommandsSettings.xaml.cs => CommandsSettings.axaml.cs} (68%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/CommandsSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/ExtensionSettings.axaml rename SubathonManager.UI/Views/SettingsViews/{ExtensionSettings.xaml.cs => ExtensionSettings.axaml.cs} (96%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/ExtensionSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml rename SubathonManager.UI/Views/SettingsViews/Extensions/{ChatExtensionSettings.xaml.cs => ChatExtensionSettings.axaml.cs} (72%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/StreamElementsSettings.axaml rename SubathonManager.UI/Views/SettingsViews/Extensions/{StreamElementsSettings.xaml.cs => StreamElementsSettings.axaml.cs} (75%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/StreamElementsSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/StreamLabsSettings.axaml rename SubathonManager.UI/Views/SettingsViews/Extensions/{StreamLabsSettings.xaml.cs => StreamLabsSettings.axaml.cs} (78%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/StreamLabsSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/TangiaSettings.axaml rename SubathonManager.UI/Views/SettingsViews/Extensions/{TangiaSettings.xaml.cs => TangiaSettings.axaml.cs} (65%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/TangiaSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.axaml rename SubathonManager.UI/Views/SettingsViews/Extensions/{TipeeeStreamSettings.xaml.cs => TipeeeStreamSettings.axaml.cs} (85%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/TreatStreamSettings.axaml rename SubathonManager.UI/Views/SettingsViews/Extensions/{TreatStreamSettings.xaml.cs => TreatStreamSettings.axaml.cs} (83%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/Extensions/TreatStreamSettings.xaml delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/DevTunnelsSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml rename SubathonManager.UI/Views/SettingsViews/External/{ExternalServiceSettings.xaml.cs => ExternalServiceSettings.axaml.cs} (68%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml rename SubathonManager.UI/Views/SettingsViews/External/{FourthWallSettings.xaml.cs => FourthWallSettings.axaml.cs} (69%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.axaml rename SubathonManager.UI/Views/SettingsViews/External/GoAffPro/{GoAffProSourceControl.xaml.cs => GoAffProSourceControl.axaml.cs} (83%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/GoAffProSettings.axaml rename SubathonManager.UI/Views/SettingsViews/External/{GoAffProSettings.xaml.cs => GoAffProSettings.axaml.cs} (70%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/GoAffProSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/JuniperSettings.axaml rename SubathonManager.UI/Views/SettingsViews/External/{JuniperSettings.xaml.cs => JuniperSettings.axaml.cs} (92%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/JuniperSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiSettings.axaml rename SubathonManager.UI/Views/SettingsViews/External/KoFi/{KoFiSettings.xaml.cs => KoFiSettings.axaml.cs} (84%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiWebhookSettings.axaml rename SubathonManager.UI/Views/SettingsViews/External/KoFi/{KoFiWebhookSettings.xaml.cs => KoFiWebhookSettings.axaml.cs} (68%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiWebhookSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/KoFiCombinedSettings.axaml rename SubathonManager.UI/Views/SettingsViews/External/{KoFiCombinedSettings.xaml.cs => KoFiCombinedSettings.axaml.cs} (85%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/KoFiCombinedSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.axaml rename SubathonManager.UI/Views/SettingsViews/External/{MakeShipSettings.xaml.cs => MakeShipSettings.axaml.cs} (90%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/MakeShipSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/PallySettings.axaml rename SubathonManager.UI/Views/SettingsViews/External/{PallySettings.xaml.cs => PallySettings.axaml.cs} (81%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/PallySettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml rename SubathonManager.UI/Views/SettingsViews/External/{ThroneSettings.xaml.cs => ThroneSettings.axaml.cs} (73%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSettings.axaml rename SubathonManager.UI/Views/SettingsViews/{ExternalSettings.xaml.cs => ExternalSettings.axaml.cs} (90%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/DevTunnelsSettings.axaml rename SubathonManager.UI/Views/SettingsViews/{External/DevTunnelsSettings.xaml.cs => ExternalSoftware/DevTunnelsSettings.axaml.cs} (61%) create mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.axaml rename SubathonManager.UI/Views/SettingsViews/ExternalSoftware/{ObsSettings.xaml.cs => ObsSettings.axaml.cs} (73%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.axaml rename SubathonManager.UI/Views/SettingsViews/ExternalSoftware/{StreamDeckSettings.xaml.cs => StreamDeckSettings.axaml.cs} (90%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.axaml rename SubathonManager.UI/Views/SettingsViews/ExternalSoftware/{StreamerBotSettings.xaml.cs => StreamerBotSettings.axaml.cs} (92%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamerBotSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftwareSettings.axaml rename SubathonManager.UI/Views/SettingsViews/{ExternalSoftwareSettings.xaml.cs => ExternalSoftwareSettings.axaml.cs} (92%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftwareSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/Streaming/PicartoSettings.axaml rename SubathonManager.UI/Views/SettingsViews/Streaming/{PicartoSettings.xaml.cs => PicartoSettings.axaml.cs} (69%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/Streaming/PicartoSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/Streaming/TwitchSettings.axaml rename SubathonManager.UI/Views/SettingsViews/Streaming/{TwitchSettings.xaml.cs => TwitchSettings.axaml.cs} (67%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/Streaming/TwitchSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.axaml rename SubathonManager.UI/Views/SettingsViews/Streaming/{YouTubeSettings.xaml.cs => YouTubeSettings.axaml.cs} (76%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/StreamingSettings.axaml rename SubathonManager.UI/Views/SettingsViews/{StreamingSettings.xaml.cs => StreamingSettings.axaml.cs} (93%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/StreamingSettings.xaml create mode 100644 SubathonManager.UI/Views/SettingsViews/WebhookLogSettings.axaml rename SubathonManager.UI/Views/SettingsViews/{WebhookLogSettings.xaml.cs => WebhookLogSettings.axaml.cs} (79%) delete mode 100644 SubathonManager.UI/Views/SettingsViews/WebhookLogSettings.xaml create mode 100644 SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml create mode 100644 SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs delete mode 100644 SubathonManager.UI/Views/WheelSpin/WheelEditor.xaml delete mode 100644 SubathonManager.UI/Views/WheelSpin/WheelEditor.xaml.cs create mode 100644 SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml create mode 100644 SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.axaml.cs delete mode 100644 SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.xaml delete mode 100644 SubathonManager.UI/Views/WheelSpin/WheelTriggerEditor.xaml.cs create mode 100644 SubathonManager.UI/app.manifest delete mode 100644 external/linux/README.md mode change 100644 => 100755 external/linux/install.sh create mode 100644 external/macos/Info.plist create mode 100644 external/macos/README.txt create mode 100755 external/macos/make-app-bundle.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9ba2ce86..8a661966 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build SubathonManager (Windows) +name: Build SubathonManager on: push: @@ -23,12 +23,39 @@ on: required: false default: 'false' type: boolean + build_windows: + description: 'Avalonia: build Windows (win-x64)' + required: false + default: true + type: boolean + build_linux: + description: 'Avalonia: build Linux (linux-x64)' + required: false + default: true + type: boolean + build_macos_arm: + description: 'Avalonia: build macOS Apple Silicon (osx-arm64)' + required: false + default: true + type: boolean + build_macos_intel: + description: 'Avalonia: build macOS Intel (osx-x64)' + required: false + default: true + type: boolean jobs: test: # if: github.event_name != 'pull_request' || github.actor != 'dependabot[bot]' # reconsider when more tests name: Run Tests & Generate Coverage runs-on: ubuntu-latest + outputs: + line_rate: ${{ steps.covstats.outputs.line_rate }} + branch_rate: ${{ steps.covstats.outputs.branch_rate }} + tests_total: ${{ steps.covstats.outputs.tests_total }} + tests_passed: ${{ steps.covstats.outputs.tests_passed }} + tests_failed: ${{ steps.covstats.outputs.tests_failed }} + tests_skipped: ${{ steps.covstats.outputs.tests_skipped }} steps: - name: Checkout repository uses: actions/checkout@v6 @@ -36,8 +63,8 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' - + dotnet-version: '10.0.x' + - name: Cache NuGet packages uses: actions/cache@v6 with: @@ -45,23 +72,38 @@ jobs: key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} restore-keys: | nuget-${{ runner.os }}- - + - name: Restore dependencies run: dotnet restore SubathonManager.Tests/SubathonManager.Tests.csproj -p:EnableWindowsTargeting=true - + - name: Run tests run: | dotnet test SubathonManager.Tests/SubathonManager.Tests.csproj \ --configuration Release \ --logger "console;verbosity=detailed" \ + --logger "trx;LogFileName=test-results.trx" \ --collect:"XPlat Code Coverage" \ --results-directory ./coverage \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="**/Migrations/**;**/*.g.cs" - + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByFile="**/Migrations/**;**/*.g.cs;**/SubathonManager.UI/**" + + - name: List raw coverage files + if: always() + shell: bash + run: ls -la coverage/**/coverage.cobertura.xml || true + + - name: Merge coverage reports + if: always() + uses: danielpalme/ReportGenerator-GitHub-Action@5 + with: + reports: coverage/**/coverage.cobertura.xml + targetdir: coverage-merged + reporttypes: Cobertura + - name: Code Coverage Summary Report + if: always() uses: irongut/CodeCoverageSummary@v1.3.0 with: - filename: coverage/**/coverage.cobertura.xml + filename: coverage-merged/Cobertura.xml fail_below_min: false hide_branch_rate: false hide_complexity: true @@ -69,32 +111,114 @@ jobs: format: markdown output: both thresholds: '60 80' - - build: - if: github.event_name != 'pull_request' || github.actor != 'dependabot[bot]' - env: - RUNTIME: win-x64 - name: Build Windows App - runs-on: windows-latest + + - name: Extract test & coverage totals + id: covstats + if: always() + shell: bash + run: | + shopt -s globstar nullglob + + line_rate=""; branch_rate="" + if [ -f coverage-merged/Cobertura.xml ]; then + line_rate=$(sed -n 's/.*]*line-rate="\([0-9.]*\)".*/\1/p' coverage-merged/Cobertura.xml | head -n1) + branch_rate=$(sed -n 's/.*]*branch-rate="\([0-9.]*\)".*/\1/p' coverage-merged/Cobertura.xml | head -n1) + fi + pct() { [ -z "$1" ] && echo "n/a" || awk -v r="$1" 'BEGIN{printf "%.1f%%", r*100}'; } + echo "line_rate=$(pct "$line_rate")" >> "$GITHUB_OUTPUT" + echo "branch_rate=$(pct "$branch_rate")" >> "$GITHUB_OUTPUT" + + total=0; passed=0; failed=0; skipped=0 + for f in coverage/**/*.trx; do + counters=$(tr -d '\n' < "$f" | sed -n 's/.*]*\)>.*/\1/p') + [ -z "$counters" ] && continue + get() { + local v + v=$(printf ' %s' "$counters" | sed -n "s/.*[ \"]$1=\"\([0-9]*\)\".*/\1/p") + printf '%s' "${v:-0}" + } + total=$((total + $(get total))) + passed=$((passed + $(get passed))) + failed=$((failed + $(get failed))) + skipped=$((skipped + $(get notExecuted))) + done + { + echo "tests_total=$total" + echo "tests_passed=$passed" + echo "tests_failed=$failed" + echo "tests_skipped=$skipped" + } >> "$GITHUB_OUTPUT" + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: code-coverage-results.md + retention-days: 3 + if-no-files-found: ignore + + avalonia-matrix: + name: Compute Avalonia build matrix + if: > + github.event_name == 'workflow_dispatch' + || (github.event_name == 'push' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v'))) needs: test + runs-on: ubuntu-latest outputs: - artifact_failed: ${{ steps.artifact-upload.outcome != 'success' }} - version: ${{ steps.vars.outputs.version }} - filename: ${{ steps.vars.outputs.filename }} - nextcloud_url: ${{ steps.nextcloud-fallback.outcome == 'success' && steps.nextcloud-fallback.outputs.SHAREABLE_URL || '' }} - artifact_name: SubathonManager_${{env.RUNTIME}}_${{steps.vars.outputs.version}} + matrix: ${{ steps.set.outputs.matrix }} + has_targets: ${{ steps.set.outputs.has_targets }} steps: - - name: Set Path Registry + - name: Build matrix from inputs + id: set + shell: bash + run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + WIN="${{ inputs.build_windows }}" + LINUX="${{ inputs.build_linux }}" + ARM="${{ inputs.build_macos_arm }}" + INTEL="${{ inputs.build_macos_intel }}" + else + WIN=true; LINUX=true; ARM=true; INTEL=true + fi + + include=() + [ "$WIN" = "true" ] && include+=('{"os":"windows-latest","runtime":"win-x64","single":"true"}') + [ "$LINUX" = "true" ] && include+=('{"os":"ubuntu-latest","runtime":"linux-x64","single":"true"}') + [ "$ARM" = "true" ] && include+=('{"os":"macos-latest","runtime":"osx-arm64","single":"false"}') + [ "$INTEL" = "true" ] && include+=('{"os":"macos-latest","runtime":"osx-x64","single":"false"}') + + joined=$(IFS=,; echo "${include[*]}") + echo "matrix={\"include\":[$joined]}" >> "$GITHUB_OUTPUT" + if [ ${#include[@]} -eq 0 ]; then + echo "has_targets=false" >> "$GITHUB_OUTPUT" + else + echo "has_targets=true" >> "$GITHUB_OUTPUT" + fi + echo "Selected matrix: {\"include\":[$joined]}" + + build-avalonia: + name: Build Avalonia App (${{ matrix.runtime }}) + if: needs.avalonia-matrix.outputs.has_targets == 'true' + needs: [test, avalonia-matrix] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.avalonia-matrix.outputs.matrix) }} + runs-on: ${{ matrix.os }} + steps: + - name: Set Path Registry (Windows long paths) + if: matrix.os == 'windows-latest' shell: pwsh run: Set-ItemProperty 'HKLM:\System\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -value 1 - + - name: Checkout repository uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Cache NuGet packages uses: actions/cache@v6 @@ -105,103 +229,354 @@ jobs: nuget-${{ runner.os }}- - name: Restore dependencies - run: dotnet restore SubathonManager.sln + run: dotnet restore SubathonManager.UI/SubathonManager.UI.csproj - - name: Determine version and filename - id: vars + - name: Set copyright year + shell: bash + run: echo "CopyrightYear=$(date +%Y)" >> $GITHUB_ENV + + - name: Determine version + id: avars shell: bash run: | - RUNTIME=${{ env.RUNTIME }} - if [ "${GITHUB_REF_NAME}" = "main" ]; then + FORCE_NIGHTLY="${{ github.event.inputs.force_nightly }}" + if [ "${GITHUB_REF_NAME}" = "main" ] || [ "$FORCE_NIGHTLY" = "true" ]; then echo "version=nightly-${GITHUB_SHA}" >> $GITHUB_OUTPUT + echo "suffix=nightly" >> $GITHUB_OUTPUT elif [ "${GITHUB_EVENT_NAME}" = "pull_request" ] || [ "${GITHUB_REF_NAME}" = "dev" ]; then echo "version=${GITHUB_SHA}" >> $GITHUB_OUTPUT + echo "suffix=${GITHUB_SHA}" >> $GITHUB_OUTPUT else SAFE_REF=${GITHUB_REF_NAME//\//-} echo "version=${SAFE_REF}" >> $GITHUB_OUTPUT + echo "suffix=${SAFE_REF}" >> $GITHUB_OUTPUT fi - - if [ "${GITHUB_REF_NAME}" = "main" ]; then - echo "filename=SubathonManager_${RUNTIME}_nightly.zip" >> $GITHUB_OUTPUT - elif [ "${GITHUB_EVENT_NAME}" = "pull_request" ] || [ "${GITHUB_REF_NAME}" = "dev" ]; then - echo "filename=SubathonManager_${RUNTIME}_${GITHUB_SHA}.zip" >> $GITHUB_OUTPUT - else - SAFE_REF=${GITHUB_REF_NAME//\//-} - echo "filename=SubathonManager_${RUNTIME}_${SAFE_REF}.zip" >> $GITHUB_OUTPUT - fi - if [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - CLEAN_VERSION="${GITHUB_REF_NAME#v}" - echo "file_version=$CLEAN_VERSION" >> $GITHUB_OUTPUT + echo "file_version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT else echo "file_version=0.0.0.0" >> $GITHUB_OUTPUT fi - - - name: Set copyright year - shell: bash - run: | - echo "CopyrightYear=$(date +%Y)" >> $GITHUB_ENV - + - name: Inject telemetry key shell: bash run: | - sed -i 's/BUILD_TELEMETRY_KEY/${{ secrets.TELEMETRY_KEY }}/g' \ - SubathonManager.Services/TelemetryService.cs + sed -i.bak 's/BUILD_TELEMETRY_KEY/${{ secrets.TELEMETRY_KEY }}/g' \ + SubathonManager.Services/TelemetryService.cs + rm -f SubathonManager.Services/TelemetryService.cs.bak - - name: Build project + - name: Publish Avalonia (${{ matrix.runtime }}) run: > - dotnet publish SubathonManager.UI/SubathonManager.UI.csproj - -c Release - -r ${{ env.RUNTIME }} - --self-contained true - -p:PublishSingleFile=true - -p:IncludeAllContentForSelfExtract=true - -p:PublishTrimmed=false - -p:FileVersion=${{ steps.vars.outputs.file_version }} - -p:AssemblyVersion=${{ steps.vars.outputs.file_version }} - -p:InformationalVersion=${{ steps.vars.outputs.version }} + dotnet publish SubathonManager.UI/SubathonManager.UI.csproj + -c Release + -r ${{ matrix.runtime }} + --self-contained true + -p:PublishSingleFile=${{ matrix.single }} + -p:IncludeNativeLibrariesForSelfExtract=${{ matrix.single }} + -p:PublishTrimmed=false + -p:FileVersion=${{ steps.avars.outputs.file_version }} + -p:AssemblyVersion=${{ steps.avars.outputs.file_version }} + -p:InformationalVersion=${{ steps.avars.outputs.version }} -p:Copyright="© 2025–${{ env.CopyrightYear }} WolfwithSword and contributors" - -o build + -o build-avalonia - name: Copy presets folder and license into build output + shell: bash + run: | + mkdir -p build-avalonia/presets + cp -R presets/. build-avalonia/presets/ + cp LICENSE build-avalonia/ + rm -f build-avalonia/*.pdb + + - name: Generate hicolor icon sizes (linux) + if: matrix.runtime == 'linux-x64' + shell: bash run: | - mkdir -Force build\presets - xcopy presets build\presets /E /I /Y - copy LICENSE build\ - rm build\*.pdb + set -euo pipefail + if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y imagemagick + fi + IM=$(command -v magick || command -v convert) + mkdir -p build-avalonia/Assets + for size in 48 64 128 256; do + "$IM" assets/icon.png -resize "${size}x${size}" \ + -background none -gravity center -extent "${size}x${size}" \ + "build-avalonia/Assets/icon_${size}.png" + done + ls -l build-avalonia/Assets/icon_*.png + + - name: Bundle Linux association helper (linux) + if: matrix.runtime == 'linux-x64' + shell: bash + run: | + mkdir -p build-avalonia/linux + cp external/linux/install.sh external/linux/subathonmanager-smo.xml build-avalonia/linux/ + chmod +x build-avalonia/linux/install.sh + + - name: Zip build (linux) + if: matrix.runtime == 'linux-x64' + shell: bash + run: | + cd build-avalonia + zip -r ../SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}.zip . + + - name: Assemble macOS .app bundle and zip (macOS only) + if: startsWith(matrix.runtime, 'osx') + shell: bash + run: | + chmod +x external/macos/make-app-bundle.sh + bash external/macos/make-app-bundle.sh build-avalonia bundle external/macos/Info.plist assets/icon.png SubathonManager "${{ steps.avars.outputs.file_version }}" + cp external/macos/README.txt bundle/ + cd bundle + zip -ry ../SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}.zip SubathonManager.app READ-ME-FIRST.txt + + - name: Zip build (windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: Compress-Archive -Path build-avalonia/* -DestinationPath SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}.zip + + - name: Resolve Nextcloud upload path + shell: bash + run: | + if [[ "${{ matrix.runtime }}" == osx-* ]]; then + echo "NC_UPLOAD_PATH=bundle" >> "$GITHUB_ENV" + else + echo "NC_UPLOAD_PATH=build-avalonia" >> "$GITHUB_ENV" + fi + + - name: Decide artifact storage + shell: bash + run: | + EVENT="${GITHUB_EVENT_NAME}" + TARGET="${{ github.event.inputs.upload_target }}" + PRIMARY=nextcloud + ALLOW_FALLBACK=false + if [ "$EVENT" = "workflow_dispatch" ] && [ "$TARGET" = "github" ]; then + PRIMARY=github; ALLOW_FALLBACK=false + elif [ "$EVENT" = "workflow_dispatch" ] && [ "$TARGET" = "nextcloud" ]; then + PRIMARY=nextcloud; ALLOW_FALLBACK=false + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + PRIMARY=github; ALLOW_FALLBACK=true + else + PRIMARY=nextcloud; ALLOW_FALLBACK=false + fi + echo "PRIMARY=$PRIMARY" >> "$GITHUB_ENV" + echo "ALLOW_FALLBACK=$ALLOW_FALLBACK" >> "$GITHUB_ENV" + echo "Storage: primary=$PRIMARY fallback=$ALLOW_FALLBACK" - name: Upload build artifact (GitHub) - if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.upload_target == 'github' || github.event.inputs.upload_target == 'default')) || - ((github.ref_type == 'tag' && github.ref != 'refs/tags/nightly') || github.ref_name == 'main') && github.ref_name != 'dev' }} + id: gh + if: ${{ env.PRIMARY == 'github' }} + continue-on-error: ${{ env.ALLOW_FALLBACK == 'true' }} uses: actions/upload-artifact@v7 - id: artifact-upload - continue-on-error: ${{ !(github.event_name == 'workflow_dispatch' && github.event.inputs.upload_target == 'github') }} with: - name: SubathonManager_${{env.RUNTIME}}_${{steps.vars.outputs.version}} - path: build + name: SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }} + path: SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}.zip retention-days: 3 - name: Upload build artifact (Nextcloud) - id: nextcloud-fallback - if: ${{ github.event_name == 'pull_request' - || (github.event_name == 'workflow_dispatch' && github.event.inputs.upload_target == 'nextcloud') - || (steps.artifact-upload.outcome != 'success' && (github.event_name != 'workflow_dispatch' || github.event.inputs.upload_target != 'github')) }} + id: nc + if: ${{ env.PRIMARY == 'nextcloud' || steps.gh.outcome == 'failure' }} uses: trympet/nextcloud-artifacts-action@v2 continue-on-error: false with: - name: SubathonManager_${{env.RUNTIME}}_${{steps.vars.outputs.version}} - path: build + name: SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }} + path: ${{ env.NC_UPLOAD_PATH }} nextcloud-url: ${{ vars.NEXTCLOUD_URL }} nextcloud-username: ${{ vars.NEXTCLOUD_USERNAME }} nextcloud-password: ${{ secrets.NEXTCLOUD_PASSWORD }} + - name: Record Nextcloud location + if: ${{ steps.nc.outcome == 'success' }} + shell: bash + run: | + echo "SubathonManager_${{ matrix.runtime }}_${{ steps.avars.outputs.suffix }}|${{ steps.nc.outputs.SHAREABLE_URL }}" \ + > "ncmeta-${{ matrix.runtime }}.txt" + + - name: Upload Nextcloud manifest + if: ${{ steps.nc.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: ncmeta-${{ matrix.runtime }} + path: ncmeta-${{ matrix.runtime }}.txt + retention-days: 3 + + - name: Record build status + if: always() + shell: bash + env: + GH_URL: ${{ steps.gh.outputs.artifact-url }} + NC_URL: ${{ steps.nc.outputs.SHAREABLE_URL }} + run: | + STATUS="${{ job.status }}" + STORAGE="-"; URL="" + if [ "${{ steps.gh.outcome }}" = "success" ] && [ -n "$GH_URL" ]; then + STORAGE="GitHub"; URL="$GH_URL" + elif [ "${{ steps.nc.outcome }}" = "success" ] && [ -n "$NC_URL" ]; then + STORAGE="Nextcloud"; URL="$NC_URL" + fi + printf '%s|%s|%s|%s\n' \ + "${{ matrix.runtime }}" "$STATUS" "$STORAGE" "$URL" \ + > "buildmeta-${{ matrix.runtime }}.txt" + cat "buildmeta-${{ matrix.runtime }}.txt" + + - name: Upload build status manifest + if: always() + uses: actions/upload-artifact@v7 + with: + name: buildmeta-${{ matrix.runtime }} + path: buildmeta-${{ matrix.runtime }}.txt + retention-days: 3 + + build-pr: + name: PR Build (win-x64) + if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' + needs: test + runs-on: windows-latest + steps: + - name: Set Path Registry (Windows long paths) + shell: pwsh + run: Set-ItemProperty 'HKLM:\System\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -value 1 + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Cache NuGet packages + uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Restore dependencies + run: dotnet restore SubathonManager.UI/SubathonManager.UI.csproj + + - name: Publish Avalonia (win-x64) + run: > + dotnet publish SubathonManager.UI/SubathonManager.UI.csproj + -c Release + -r win-x64 + --self-contained true + -p:PublishSingleFile=false + -p:PublishTrimmed=false + -o build-pr + + - name: Copy presets folder and license into build output + shell: bash + run: | + mkdir -p build-pr/presets + cp -R presets/. build-pr/presets/ + cp LICENSE build-pr/ + rm -f build-pr/*.pdb + + - name: Upload PR build artifact + uses: actions/upload-artifact@v7 + with: + name: SubathonManager_pr-${{ github.event.pull_request.number }}_win-x64 + path: build-pr + retention-days: 2 + + summary: + name: Build Summary + runs-on: ubuntu-latest + needs: [test, avalonia-matrix, build-avalonia, build-pr, release] + if: always() + steps: + - name: Download build status manifests + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: buildmeta-* + path: buildmeta + merge-multiple: true + + - name: Download coverage report + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: coverage-report + path: coverage-report + + - name: Render summary + shell: bash + run: | + icon() { + case "$1" in + success) echo "✅ Success" ;; + failure) echo "❌ Failed" ;; + cancelled) echo "⚠️ Cancelled" ;; + skipped) echo "⏭️ Skipped" ;; + *) echo "❔ $1" ;; + esac + } + + { + echo "# SubathonManager Build Summary" + echo + echo "**Ref:** \`${GITHUB_REF_NAME}\`  •  **Commit:** \`${GITHUB_SHA:0:8}\`  •  **Trigger:** \`${GITHUB_EVENT_NAME}\`" + echo + echo "## Jobs" + echo + echo "| Job | Result |" + echo "| --- | --- |" + echo "| Tests & Coverage | $(icon "${{ needs.test.result }}") |" + echo "| Builds | $(icon "${{ needs['build-avalonia'].result }}") |" + echo "| PR build (win-x64) | $(icon "${{ needs['build-pr'].result }}") |" + echo "| Release | $(icon "${{ needs.release.result }}") |" + echo + echo "## Tests" + echo + echo "| Total | Passed | Failed | Skipped |" + echo "| --- | --- | --- | --- |" + echo "| ${{ needs.test.outputs.tests_total || 'n/a' }} | ✅ ${{ needs.test.outputs.tests_passed || '0' }} | ❌ ${{ needs.test.outputs.tests_failed || '0' }} | ⏭️ ${{ needs.test.outputs.tests_skipped || '0' }} |" + echo + echo "## Coverage" + echo + echo "| Metric | Value |" + echo "| --- | --- |" + echo "| Line rate | ${{ needs.test.outputs.line_rate || 'n/a' }} |" + echo "| Branch rate | ${{ needs.test.outputs.branch_rate || 'n/a' }} |" + echo + if [ -f coverage-report/code-coverage-results.md ]; then + echo "
Per-assembly coverage" + echo + cat coverage-report/code-coverage-results.md + echo + echo "
" + echo + fi + echo "## Platform Builds" + echo + echo "| Platform | Result | Storage | Artifact |" + echo "| --- | --- | --- | --- |" + shopt -s nullglob + found=0 + for f in buildmeta/*.txt; do + found=1 + IFS='|' read -r RUNTIME STATUS STORAGE URL < "$f" + if [ -n "$URL" ]; then + LINK="[Download]($URL)" + else + LINK="-" + fi + echo "| \`${RUNTIME}\` | $(icon "$STATUS") | ${STORAGE:-–} | ${LINK} |" + done + if [ "$found" -eq 0 ]; then + echo "| - | no platform builds ran | - | - |" + fi + echo + echo "[View full run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + } >> "$GITHUB_STEP_SUMMARY" release: - env: - RUNTIME: win-x64 name: Create Release runs-on: ubuntu-latest - needs: build + needs: [test, build-avalonia] outputs: release_ok: ${{ steps.create_release.outcome == 'success' }} if: > @@ -216,88 +591,105 @@ jobs: sparse-checkout: | external sparse-checkout-cone-mode: false - - - name: Download build artifact (GitHub) - if: needs.build.outputs.artifact_failed == 'false' && github.event.inputs.upload_target != 'nextcloud' + + - name: Install tooling + run: | + sudo apt-get update + sudo apt-get install -y imagemagick zip unzip curl + + - name: Download GitHub build artifacts + continue-on-error: true uses: actions/download-artifact@v8 with: - name: ${{ needs.build.outputs.artifact_name }} - path: build/ + pattern: SubathonManager_* + path: dist + merge-multiple: true - - name: Download build artifact (Nextcloud) - if: needs.build.outputs.artifact_failed == 'true' && github.event.inputs.upload_target != 'github' + - name: Download Nextcloud manifests + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: ncmeta-* + path: ncmeta + merge-multiple: true + + - name: Resolve Nextcloud artifacts + shell: bash run: | - echo "Downloading release zip from Nextcloud fallback..." - mkdir build - curl -L "${{ needs.build.outputs.nextcloud_url }}/download?path=&files=${{ needs.build.outputs.artifact_name }}.zip" \ - -o "build/${{ needs.build.outputs.filename }}" - mv build/${{ needs.build.outputs.filename }} ./ - - - name: Zip release files - if: needs.build.outputs.artifact_failed == 'false' && github.event.inputs.upload_target != 'nextcloud' + mkdir -p dist + shopt -s nullglob + for f in ncmeta/*.txt; do + NAME="$(cut -d'|' -f1 "$f")" + URL="$(cut -d'|' -f2 "$f")" + [ -z "$NAME" ] && continue + [ -z "$URL" ] && continue + if [ -f "dist/${NAME}.zip" ]; then + echo "Already have ${NAME}.zip from GitHub"; continue + fi + echo "Fetching ${NAME}.zip from Nextcloud..." + curl -fL "${URL}/download?path=&files=${NAME}.zip" -o outer.zip + rm -rf nctmp && mkdir nctmp + unzip -o outer.zip -d nctmp || true + if [ -f "nctmp/${NAME}.zip" ]; then + mv "nctmp/${NAME}.zip" "dist/${NAME}.zip" + else + cp outer.zip "dist/${NAME}.zip" + fi + rm -rf nctmp outer.zip + done + echo "Release assets:"; ls -la dist || true + + - name: Ensure at least one build is present + shell: bash run: | - cd build - zip -r ../${{ needs.build.outputs.filename }} . - echo "${{ needs.build.outputs.filename }} successfully zipped" - + if ! ls dist/*.zip >/dev/null 2>&1; then + echo "ERROR: no build zips found (GitHub or Nextcloud)"; exit 1 + fi + - name: Fetch SB Extension shell: pwsh run: | New-Item -ItemType Directory -Force -Path "external/streamerbot" | Out-Null - + Invoke-WebRequest ` -Uri "https://sb-assets.wolfwithsword.com/extensions/subathonmanager/latest/subathonmanager-extension.sb" ` -OutFile "external/streamerbot/SubathonManager_Extension.sb" - - - name: Move files - run: | - if [ -f "build/${{ needs.build.outputs.filename }}" ]; then - mv build/${{ needs.build.outputs.filename }} ./ - fi - if [ ! -f "${{ needs.build.outputs.filename }}" ]; then - echo "ERROR: ZIP not found!" - exit 1 - fi - cp external/streamerbot/*.sb ./ || true - - name: Install ImageMagick - run: | - sudo apt-get update - sudo apt-get install -y imagemagick zip - + - name: Stage streamerbot extension + run: cp external/streamerbot/*.sb ./ || true + - name: Download icon shell: bash - run: | - curl -L "https://assets.subathonmanager.app/docs/icon.png" -o icon.png - + run: curl -L "https://assets.subathonmanager.app/docs/icon.png" -o icon.png + - name: Package Stream Deck plugin shell: bash run: | set -e - + sdplugin="external/streamdeck/com.wolfwithsword.subathonmanager.sdPlugin" mkdir -p "$sdplugin/images" - + for spec in pluginIcon:28 categoryIcon:28 actionIcon:20 keyIcon:72; do name="${spec%%:*}" size="${spec##*:}" - + convert icon.png -resize "${size}x${size}" "$sdplugin/images/${name}.png" convert icon.png -resize "$((size*2))x$((size*2))" "$sdplugin/images/${name}@2x.png" done - + ( cd external/streamdeck zip -r ../../SubathonManager_StreamDeck.streamDeckPlugin com.wolfwithsword.subathonmanager.sdPlugin ) - + - name: Create GitHub Release id: create_release if: startsWith(github.ref, 'refs/tags/') && github.ref != 'refs/tags/nightly' && (github.event_name != 'workflow_dispatch' || github.event.inputs.force_nightly != 'true') uses: softprops/action-gh-release@v3 with: files: | - ${{ needs.build.outputs.filename }} + dist/*.zip *.sb *.streamDeckPlugin env: @@ -328,7 +720,7 @@ jobs: body: "Latest build from main branch." prerelease: true files: | - ${{ needs.build.outputs.filename }} + dist/*.zip *.sb *.streamDeckPlugin token: ${{ secrets.GITHUB_TOKEN }} @@ -374,12 +766,12 @@ jobs: ] } EOF - + curl -X POST \ -H "Content-Type: application/json" \ -d @payload.json \ "$WEBHOOK_URL" - + curl -X POST \ -H "Content-Type: application/json" \ -d @payload.json \ diff --git a/.github/workflows/wiki-drawio-upload.yml b/.github/workflows/wiki-drawio-upload.yml index 2dc0a75f..e1b95d07 100644 --- a/.github/workflows/wiki-drawio-upload.yml +++ b/.github/workflows/wiki-drawio-upload.yml @@ -19,6 +19,18 @@ jobs: name: Update Wiki Documentation runs-on: ubuntu-latest steps: + - name: Normalize release tag + id: version + run: | + TAG="${{ inputs.release_tag || 'nightly' }}" + + if [[ "$TAG" == "nightly" || "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" + else + echo "Invalid release tag '$TAG', using nightly." + echo "release_tag=nightly" >> "$GITHUB_OUTPUT" + fi + - name: Check if wiki repository exists id: wiki-check run: | @@ -76,22 +88,29 @@ jobs: if git diff --cached --quiet; then echo "No changes detected, skipping commit." else - git commit -m "Update Subathon_Manager.drawio for release ${{ inputs.release_tag || 'nightly' }}" + git commit -m "Update Subathon_Manager.drawio for release ${{ steps.version.outputs.release_tag }}" git push fi - + outputs: + release_tag: ${{ steps.version.outputs.release_tag }} trigger-docs-release: runs-on: ubuntu-latest needs: update - if: ${{ (inputs.release_tag || 'nightly') != 'nightly' }} + if: ${{ needs.update.result == 'success' && needs.version.outputs.release_tag != 'nightly' && needs.version.outputs.release_tag != '' }} steps: - name: Dispatch docs release uses: actions/github-script@v9 + env: + VERSION: ${{ needs.version.outputs.release_tag }} with: github-token: ${{ secrets.DOCS_DISPATCH_TOKEN }} script: | - const version = context.payload.inputs?.release_tag || 'nightly'; + const version = process.env.VERSION; + if (!version || version === 'nightly') { + core.setFailed(`Refusing to dispatch a docs release for version '${version}'.`); + return; + } await github.rest.repos.createDispatchEvent({ owner: 'WolfwithSword', repo: 'SubathonManager-docs', diff --git a/.gitignore b/.gitignore index 7b2d8393..03709b50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +presets/harley/ +presets/honse/ + bin/ obj/ /packages/ diff --git a/SubathonManager.Core/AppServices.cs b/SubathonManager.Core/AppServices.cs index d89a01fb..bf0c2e54 100644 --- a/SubathonManager.Core/AppServices.cs +++ b/SubathonManager.Core/AppServices.cs @@ -1,7 +1,7 @@ using Updatum; using System.Reflection; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; namespace SubathonManager.Core { @@ -26,9 +26,22 @@ public static string AppVersion { AssetExtensionFilter = "zip", CurrentVersion = GetVersion(), - FetchOnlyLatestRelease = true + FetchOnlyLatestRelease = true, + AssetRegexPattern = GetAssetRegexPattern(), }; + private static string GetAssetRegexPattern() + { + string rid = + OperatingSystem.IsWindows() ? "win-x64" : + OperatingSystem.IsLinux() ? "linux-x64" : + OperatingSystem.IsMacOS() + ? (RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64") + : string.Empty; + + return string.IsNullOrEmpty(rid) ? string.Empty : $@"(?i).*{rid}.*\.zip$"; + } + private static Version GetVersion() { if (string.IsNullOrWhiteSpace(AppVersion)) @@ -96,13 +109,7 @@ public static async Task InstallUpdate(UpdatumDownloadedAsset? asset, ILog try { - Process.Start(new ProcessStartInfo - { - FileName = Config.AppFolder, - UseShellExecute = true, - Verb = "open" - }); - await AppUpdater.InstallUpdateAsync(asset, true, UpdatumManager.NoRunAfterUpgradeToken); + await AppUpdater.InstallUpdateAsync(asset, true); return true; } catch (Exception ex) diff --git a/SubathonManager.Core/Enums/SubathonPromptType.cs b/SubathonManager.Core/Enums/SubathonPromptType.cs index 8b216a2b..cd22b627 100644 --- a/SubathonManager.Core/Enums/SubathonPromptType.cs +++ b/SubathonManager.Core/Enums/SubathonPromptType.cs @@ -73,7 +73,11 @@ public static SubathonPromptSubType[] GetValidSubTypes( return type.GetValidSubTypes(); if (filterEventType.IsSubscription()) + { + if (filterEventType == SubathonEventType.YouTubeGiftMembership) // no tier selection + return [SubathonPromptSubType.Default]; return [SubathonPromptSubType.Default, SubathonPromptSubType.ByTier]; + } if (filterEventType.IsOrder()) { // makeship and juniper only ever track unit counts between polls, but do it diff diff --git a/SubathonManager.Core/Objects/WidgetMeta.cs b/SubathonManager.Core/Objects/WidgetMeta.cs index 63a85b65..7ad96841 100644 --- a/SubathonManager.Core/Objects/WidgetMeta.cs +++ b/SubathonManager.Core/Objects/WidgetMeta.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Text.Json.Serialization; using SubathonManager.Core.Enums; @@ -13,6 +14,7 @@ public class WidgetMeta public Dictionary Vars { get; set; } = new(); } +[ExcludeFromCodeCoverage] public class WidgetMetaVar { [JsonIgnore] diff --git a/SubathonManager.Integration/ExternalEventService.cs b/SubathonManager.Integration/ExternalEventService.cs index 47b5335c..9b8efb11 100644 --- a/SubathonManager.Integration/ExternalEventService.cs +++ b/SubathonManager.Integration/ExternalEventService.cs @@ -88,7 +88,17 @@ public static bool ProcessExternalSub(Dictionary data) } } - subathonEvent.Amount = elemAmount.ValueKind == JsonValueKind.Number ? elemAmount.GetInt16() : 1; + if (elemAmount.ValueKind == JsonValueKind.String) + { + subathonEvent.Amount = 1; + if (int.TryParse(elemAmount.GetString(), out var amtInt) && amtInt > 0) + subathonEvent.Amount = amtInt; + } + else + { + subathonEvent.Amount = elemAmount.ValueKind == JsonValueKind.Number ? elemAmount.GetInt16() : 1; + } + subathonEvent.Source = user == "SYSTEM" ? SubathonEventSource.Simulated : ((SubathonEventType?)type).GetSource(); subathonEvent.EventType = type; diff --git a/SubathonManager.Integration/TwitchService.cs b/SubathonManager.Integration/TwitchService.cs index a5f105d9..082cbc16 100644 --- a/SubathonManager.Integration/TwitchService.cs +++ b/SubathonManager.Integration/TwitchService.cs @@ -379,7 +379,8 @@ private Task HandleMessageCmdReceived(object? s, OnMessageReceivedArgs e) e.ChatMessage.Username, // DisplayName isBroadcaster, isMod, isVip, DateTime.Now); } - else if (e.ChatMessage.DisplayName.Equals("blerp", StringComparison.InvariantCultureIgnoreCase)) + else if (e.ChatMessage.DisplayName.Equals("blerp", StringComparison.InvariantCultureIgnoreCase) + && config.GetBool("Extensions", "Blerp.Enabled", true)) { BlerpChatService.ParseMessage(e.ChatMessage.Message, SubathonEventSource.Twitch); } diff --git a/SubathonManager.Integration/YouTubeService.cs b/SubathonManager.Integration/YouTubeService.cs index 755df046..169e2f11 100644 --- a/SubathonManager.Integration/YouTubeService.cs +++ b/SubathonManager.Integration/YouTubeService.cs @@ -347,7 +347,8 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) CommandService.ChatCommandRequest(SubathonEventSource.YouTube, messagePreview, user, item.IsOwner, item.IsModerator, false, item.Timestamp.DateTime.ToLocalTime(), Utils.CreateGuidFromUniqueString(item.Id)); - else if (user.Equals("blerp", StringComparison.OrdinalIgnoreCase)) + else if (user.Equals("blerp", StringComparison.OrdinalIgnoreCase) + && _config.GetBool("Extensions", "Blerp.Enabled", true)) { BlerpChatService.ParseMessage(messagePreview, SubathonEventSource.YouTube); } diff --git a/SubathonManager.Server/AspNetContextAdapter.cs b/SubathonManager.Server/AspNetContextAdapter.cs new file mode 100644 index 00000000..b307d9a6 --- /dev/null +++ b/SubathonManager.Server/AspNetContextAdapter.cs @@ -0,0 +1,64 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.WebSockets; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; + +namespace SubathonManager.Server; + +[ExcludeFromCodeCoverage] +public sealed class AspNetContextAdapter(HttpContext ctx) : IHttpContext +{ + public string Method => ctx.Request.Method; + public string Path => ctx.Request.Path.Value ?? "/"; + public string QueryString => ctx.Request.QueryString.Value?.TrimStart('?') ?? string.Empty; + public Stream Body => ctx.Request.Body; + public Encoding Encoding => Encoding.UTF8; + public bool IsWebSocket => ctx.WebSockets.IsWebSocketRequest; + + public IReadOnlyDictionary Headers + { + get + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in ctx.Request.Headers) + dict[key] = value.ToString(); + return dict; + } + } + + public Task? AcceptWebSocketAsync(string? subProtocol = null) + => ctx.WebSockets.IsWebSocketRequest + ? ctx.WebSockets.AcceptWebSocketAsync(subProtocol) + : null; + + public async Task WriteResponse(int code, string body, bool addCors = false, string? contentType = null) + { + ctx.Response.StatusCode = code; + if (contentType != null) + ctx.Response.ContentType = contentType; + if (addCors) + AddCorsHeaders(ctx.Response); + await ctx.Response.WriteAsync(body, Encoding.UTF8); + } + + public async Task ServeFile(string fullPath, string contentType) + { + ctx.Response.ContentType = contentType; + AddCorsHeaders(ctx.Response); + await ctx.Response.SendFileAsync(fullPath); + } + + private static void AddCorsHeaders(HttpResponse response) + { + response.Headers["Access-Control-Allow-Origin"] = "*"; + response.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; + response.Headers["Access-Control-Allow-Headers"] = "Content-Type"; + } +} + +internal sealed class NoopHostLifetime : IHostLifetime +{ + public Task WaitForStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/SubathonManager.Server/HttpListenerContextAdapter.cs b/SubathonManager.Server/HttpListenerContextAdapter.cs deleted file mode 100644 index ec8d947e..00000000 --- a/SubathonManager.Server/HttpListenerContextAdapter.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Net; -using System.Text; -using System.Net.WebSockets; -using System.Diagnostics.CodeAnalysis; -namespace SubathonManager.Server; - - -[ExcludeFromCodeCoverage] -public sealed class HttpListenerContextAdapter : IHttpContext -{ - private readonly HttpListenerContext _ctx; - - public HttpListenerContextAdapter(HttpListenerContext ctx) - { - _ctx = ctx; - } - - public string Method => _ctx.Request.HttpMethod; - public string Path => _ctx.Request.Url!.AbsolutePath; - public Stream Body => _ctx.Request.InputStream; - public string QueryString => _ctx.Request.Url!.Query.TrimStart('?'); - public Encoding Encoding => _ctx.Request.ContentEncoding; - public bool IsWebSocket => _ctx.Request.IsWebSocketRequest; - - public IReadOnlyDictionary Headers - { - get - { - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - var headers = _ctx.Request.Headers; - foreach (string? key in headers.AllKeys) - { - if (key != null) - dict[key] = headers[key] ?? string.Empty; - } - return dict; - } - } - - public Task? AcceptWebSocketAsync(string? subProtocol = null) - { - if (!_ctx.Request.IsWebSocketRequest) - return null; - - return AcceptInternalAsync(subProtocol); - } - - private async Task AcceptInternalAsync(string? subProtocol) - { - var wsContext = await _ctx.AcceptWebSocketAsync(subProtocol); - return wsContext.WebSocket; - } - - public async Task WriteResponse(int code, string body, bool addCors = false, string? contentType = null) - { - _ctx.Response.StatusCode = code; - if (contentType != null) - _ctx.Response.ContentType = contentType; - - if (addCors) - AddCorsHeaders(_ctx.Response); - var bytes = Encoding.UTF8.GetBytes(body); - await _ctx.Response.OutputStream.WriteAsync(bytes); - _ctx.Response.Close(); - } - - public async Task ServeFile(string fullPath, string contentType) - { - _ctx.Response.ContentType = contentType; - AddCorsHeaders(_ctx.Response); - await using var fs = File.OpenRead(fullPath); - _ctx.Response.ContentLength64 = fs.Length; - await fs.CopyToAsync(_ctx.Response.OutputStream); - _ctx.Response.Close(); - } - - private static void AddCorsHeaders(HttpListenerResponse response) - { - response.AddHeader("Access-Control-Allow-Origin", "*"); - response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - response.AddHeader("Access-Control-Allow-Headers", "Content-Type"); - } -} \ No newline at end of file diff --git a/SubathonManager.Server/SubathonManager.Server.csproj b/SubathonManager.Server/SubathonManager.Server.csproj index 52d2794c..a186f7b8 100644 --- a/SubathonManager.Server/SubathonManager.Server.csproj +++ b/SubathonManager.Server/SubathonManager.Server.csproj @@ -9,6 +9,10 @@ $(NoWarn);CS8981 + + + + diff --git a/SubathonManager.Server/TcpListenerContextAdapter.cs b/SubathonManager.Server/TcpListenerContextAdapter.cs deleted file mode 100644 index d2d4b4c7..00000000 --- a/SubathonManager.Server/TcpListenerContextAdapter.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net.Sockets; -using System.Net.WebSockets; -using System.Security.Cryptography; -using System.Text; - -namespace SubathonManager.Server; - -[ExcludeFromCodeCoverage] // This is a beta feature as a fallback for non-windows systems or WINE/Proton/Other without HTTPListener support -public sealed class TcpListenerContextAdapter : IHttpContext -{ - private readonly TcpClient _client; - private readonly NetworkStream _stream; - private readonly string _rawRequest; - private readonly Dictionary _headers; - private readonly byte[] _bodyBytes; - - public string Method { get; } - public string Path { get; } - public string QueryString { get; } - public IReadOnlyDictionary Headers => _headers; - public Encoding Encoding => Encoding.UTF8; - public Stream Body { get; } - - public bool IsWebSocket => - _headers.TryGetValue("upgrade", out var v) && - v.Equals("websocket", StringComparison.OrdinalIgnoreCase); - - private TcpListenerContextAdapter( - TcpClient client, - NetworkStream stream, - string method, - string path, - string queryString, - Dictionary headers, - byte[] body, - string rawRequest) - { - _client = client; - _stream = stream; - Method = method; - Path = path; - QueryString = queryString; - _headers = headers; - _bodyBytes = body; - Body = new MemoryStream(body); - _rawRequest = rawRequest; - } - - public static async Task ParseAsync( - TcpClient client, - CancellationToken ct = default) - { - var stream = client.GetStream(); - - var headerBuffer = new List(4096); - var oneByte = new byte[1]; - while (true) - { - if (ct.IsCancellationRequested) return null; - int read = await stream.ReadAsync(oneByte, ct); - if (read == 0) return null; - headerBuffer.Add(oneByte[0]); - - if (headerBuffer.Count >= 4) - { - int tail = headerBuffer.Count; - if (headerBuffer[tail - 4] == '\r' && headerBuffer[tail - 3] == '\n' && - headerBuffer[tail - 2] == '\r' && headerBuffer[tail - 1] == '\n') - break; - } - - if (headerBuffer.Count > 65536) return null; - } - - string rawHeader = Encoding.UTF8.GetString(headerBuffer.ToArray()); - var lines = rawHeader.Split("\r\n", StringSplitOptions.None); - if (lines.Length == 0) return null; - - var requestLine = lines[0].Split(' '); - if (requestLine.Length < 2) return null; - - string method = requestLine[0].ToUpperInvariant(); - string rawPath = requestLine.Length >= 2 ? requestLine[1] : "/"; - - string path, query; - int qIdx = rawPath.IndexOf('?'); - if (qIdx >= 0) - { - path = Uri.UnescapeDataString(rawPath[..qIdx]); - query = rawPath[(qIdx + 1)..]; - } - else - { - path = Uri.UnescapeDataString(rawPath); - query = string.Empty; - } - - var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - for (int i = 1; i < lines.Length; i++) - { - var line = lines[i]; - if (string.IsNullOrWhiteSpace(line)) break; - int colon = line.IndexOf(':'); - if (colon < 0) continue; - headers[line[..colon].Trim().ToLowerInvariant()] = line[(colon + 1)..].Trim(); - } - - byte[] body = []; - if (headers.TryGetValue("content-length", out var clStr) && - int.TryParse(clStr, out int contentLength) && contentLength > 0) - { - body = new byte[contentLength]; - int totalRead = 0; - while (totalRead < contentLength) - { - int n = await stream.ReadAsync(body.AsMemory(totalRead, contentLength - totalRead), ct); - if (n == 0) break; - totalRead += n; - } - } - - return new TcpListenerContextAdapter(client, stream, method, path, query, headers, body, rawHeader); - } - - public Task? AcceptWebSocketAsync(string? subProtocol = null) - { - if (!IsWebSocket) return null; - return DoWebSocketUpgradeAsync(subProtocol); - } - - private async Task DoWebSocketUpgradeAsync(string? subProtocol) - { - if (!_headers.TryGetValue("sec-websocket-key", out var key)) - throw new InvalidOperationException("Missing Sec-WebSocket-Key header"); - - string acceptKey = Convert.ToBase64String( - SHA1.HashData( - Encoding.UTF8.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))); // hardcode - - var sb = new StringBuilder(); - sb.Append("HTTP/1.1 101 Switching Protocols\r\n"); - sb.Append("Upgrade: websocket\r\n"); - sb.Append("Connection: Upgrade\r\n"); - sb.Append($"Sec-WebSocket-Accept: {acceptKey}\r\n"); - if (!string.IsNullOrEmpty(subProtocol)) - sb.Append($"Sec-WebSocket-Protocol: {subProtocol}\r\n"); - sb.Append("\r\n"); - - byte[] handshake = Encoding.UTF8.GetBytes(sb.ToString()); - await _stream.WriteAsync(handshake); - await _stream.FlushAsync(); - - return WebSocket.CreateFromStream(_stream, new WebSocketCreationOptions - { - IsServer = true, - SubProtocol = subProtocol, - KeepAliveInterval = TimeSpan.FromSeconds(30) - }); - } - - public async Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null) - { - byte[] bodyBytes = Encoding.UTF8.GetBytes(body); - string reasonPhrase = statusCode switch - { - 200 => "OK", 201 => "Created", 204 => "No Content", - 400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden", - 404 => "Not Found", 500 => "Internal Server Error", - _ => "OK" - }; - - var sb = new StringBuilder(); - sb.Append($"HTTP/1.1 {statusCode} {reasonPhrase}\r\n"); - sb.Append($"Content-Type: {contentType ?? "text/plain"}; charset=utf-8\r\n"); - sb.Append($"Content-Length: {bodyBytes.Length}\r\n"); - if (addCors) AppendCorsHeaders(sb); - sb.Append("Connection: close\r\n\r\n"); - - byte[] headerBytes = Encoding.UTF8.GetBytes(sb.ToString()); - await _stream.WriteAsync(headerBytes); - await _stream.WriteAsync(bodyBytes); - await _stream.FlushAsync(); - Close(); - } - - public async Task ServeFile(string fullPath, string contentType) - { - await using var fs = File.OpenRead(fullPath); - long length = fs.Length; - - var sb = new StringBuilder(); - sb.Append("HTTP/1.1 200 OK\r\n"); - sb.Append($"Content-Type: {contentType}\r\n"); - sb.Append($"Content-Length: {length}\r\n"); - AppendCorsHeaders(sb); - sb.Append("Connection: close\r\n\r\n"); - - byte[] headerBytes = Encoding.UTF8.GetBytes(sb.ToString()); - await _stream.WriteAsync(headerBytes); - await fs.CopyToAsync(_stream); - await _stream.FlushAsync(); - Close(); - } - - private static void AppendCorsHeaders(StringBuilder sb) - { - sb.Append("Access-Control-Allow-Origin: *\r\n"); - sb.Append("Access-Control-Allow-Methods: GET, POST, PATCH, PUT, OPTIONS\r\n"); - sb.Append("Access-Control-Allow-Headers: Content-Type\r\n"); - } - - private void Close() - { - try { _stream.Close(); } catch { /* ignored */ } - try { _client.Close(); } catch { /* ignored */ } - } -} \ No newline at end of file diff --git a/SubathonManager.Server/WebServer.Overlays.cs b/SubathonManager.Server/WebServer.Overlays.cs index 12e94e31..a7111f23 100644 --- a/SubathonManager.Server/WebServer.Overlays.cs +++ b/SubathonManager.Server/WebServer.Overlays.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.Globalization; +using System.Net; using System.Text; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; @@ -211,6 +212,10 @@ private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false height: {route.Height}px; margin: 0 auto; background: transparent; + transform-origin: top left; + --handle-size: 12px; + --handle-half: 6px; + --chrome-outline: 2px; }} .overlay-edit {{ @@ -219,12 +224,13 @@ private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false .widget-wrapper {{ position: absolute; - pointer-events: auto; - background: rgba(0,0,0,0); - user-select: none; + pointer-events: auto; + background: rgba(0,0,0,0); + user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; + transform-origin: top left; }} .widget-wrapper iframe {{ @@ -233,7 +239,6 @@ private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false border: none; background: transparent; pointer-events: none; - transform-origin: top left; }} @keyframes pulse {{ @@ -241,32 +246,53 @@ @keyframes pulse {{ 50% {{ box-shadow: 0 0 16px rgba(100,180,255,0.6); }} 100% {{ box-shadow: 0 0 10px rgba(255,255,255,0.3); }} }} - .widget-edit {{ - outline: 2px solid; + + #chrome-layer {{ + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; + }} + + .widget-chrome {{ + position: absolute; + pointer-events: auto; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + outline: var(--chrome-outline) solid; cursor: move; outline-color: rgba(50, 50, 50, 0.4); border-radius: 8px; box-shadow: 0 0 10px rgba(55,55,55,0.3); transition: outline-color 0.2s, box-shadow 0.2s; - }} - .widget-edit:hover {{ + }} + .widget-chrome:hover {{ outline-color: rgba(100, 180, 255, 0.9); animation: pulse 1.5s infinite; - }} - .widget-hidden {{ + }} + .chrome-hidden {{ opacity: 0.4 !important; outline-color: rgba(60, 10, 10, 0.4); }} - + + .widget-hidden {{ + opacity: 0.4 !important; + }} + .resize-handle {{ position: absolute; - width: 12px; - height: 12px; + width: var(--handle-size); + height: var(--handle-size); background: rgba(180,180,255,0.7); - border: 1px solid rgba(100,100,255,0.9); + border: calc(var(--chrome-outline) / 2) solid rgba(100,100,255,0.9); border-radius: 2px; z-index: 9999; cursor: pointer; + box-sizing: border-box; }} .resize-handle.shift-active {{ @@ -279,15 +305,15 @@ @keyframes pulse {{ border-color: #20b860 !important; }} - .handle-nw {{ top: -6px; left: -6px; cursor: nwse-resize; }} - .handle-ne {{ top: -6px; right: -6px; cursor: nesw-resize; }} - .handle-sw {{ bottom: -6px; left: -6px; cursor: nesw-resize; }} - .handle-se {{ bottom: -6px; right: -6px; cursor: nwse-resize; }} + .handle-nw {{ top: calc(0px - var(--handle-half)); left: calc(0px - var(--handle-half)); cursor: nwse-resize; }} + .handle-ne {{ top: calc(0px - var(--handle-half)); right: calc(0px - var(--handle-half)); cursor: nesw-resize; }} + .handle-sw {{ bottom: calc(0px - var(--handle-half)); left: calc(0px - var(--handle-half)); cursor: nesw-resize; }} + .handle-se {{ bottom: calc(0px - var(--handle-half)); right: calc(0px - var(--handle-half)); cursor: nwse-resize; }} - .handle-n {{ top: -6px; left: 50%; transform: translateX(-50%); cursor: n-resize; }} - .handle-s {{ bottom: -6px; left: 50%; transform: translateX(-50%); cursor: s-resize; }} - .handle-e {{ right: -6px; top: 50%; transform: translateY(-50%); cursor: e-resize; }} - .handle-w {{ left: -6px; top: 50%; transform: translateY(-50%); cursor: w-resize; }} + .handle-n {{ top: calc(0px - var(--handle-half)); left: 50%; transform: translateX(-50%); cursor: n-resize; }} + .handle-s {{ bottom: calc(0px - var(--handle-half)); left: 50%; transform: translateX(-50%); cursor: s-resize; }} + .handle-e {{ right: calc(0px - var(--handle-half)); top: 50%; transform: translateY(-50%); cursor: e-resize; }} + .handle-w {{ left: calc(0px - var(--handle-half)); top: 50%; transform: translateY(-50%); cursor: w-resize; }} "); if (isEditor) @@ -315,76 +341,125 @@ @keyframes pulse {{ if (w.ScaleY == 0) w.ScaleY = 1; string cssClass = "widget-wrapper"; - if (isEditor) cssClass += " widget-edit"; if (!w.Visibility) cssClass += " widget-hidden"; - string titleAttr = isEditor ? $" title=\"{WebUtility.HtmlEncode(w.Name)}\nZ:{w.Z}\"" : ""; - sb.AppendLine($@"
"); - if (isEditor) - { - sb.AppendLine(@" -
-
-
- -
- -
- -
-
- -
- "); - } - - sb.AppendLine($@""); sb.AppendLine("
"); } + if (isEditor) + { + sb.AppendLine(@"
"); + foreach (var w in route.Widgets) + { + string chromeClass = "widget-chrome"; + if (!w.Visibility) chromeClass += " chrome-hidden"; + + sb.AppendLine($@"
+
+
+
+
+
+
+
+
+
"); + } + sb.AppendLine("
"); + } + sb.AppendLine(""); sb.AppendLine(@$" "); @@ -395,17 +470,19 @@ function resizeIframe(iframe) {{ { sb.AppendLine(@" + "; + private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false) { StringBuilder sb = new StringBuilder(); @@ -775,6 +864,8 @@ function updateHandleIndicators() {{ }}); "); + + sb.AppendLine(BuildWidgetContextMenuScript()); } sb.AppendLine(""); 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..08ed0e84 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; @@ -86,6 +87,7 @@ private void SetupOverlayRoutes() _routes.Add((new RouteKey("GET", "/ws"),HandleWebSocketRequestAsync)); _routes.Add((new RouteKey("GET", "/widget/"),HandleWidgetRequest )); _routes.Add((new RouteKey("GET", "/route/"),HandleRouteRequest )); + _routes.Add((new RouteKey("GET", ResourcePaths.UrlPrefix),HandleResourceRequest )); } public async Task StartAsync(CancellationToken cancellationToken = default) diff --git a/SubathonManager.Services/CommandService.cs b/SubathonManager.Services/CommandService.cs index 429e74a0..a2e69063 100644 --- a/SubathonManager.Services/CommandService.cs +++ b/SubathonManager.Services/CommandService.cs @@ -4,6 +4,7 @@ using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Services; @@ -66,14 +67,13 @@ private static bool ValidateUser(SubathonEvent subathonEvent, string user, bool if (isBroadcaster) return true; string configKey = $"Commands.{subathonEvent.Command}.permissions"; - // All Commands are under twitch section at the moment, despite being cross platform chat commands - // Should update eventually, or is it too late if (isModerator && AppConfig!.GetBool("Chat", $"{configKey}.Mods", false)) return true; if (isVip && AppConfig!.GetBool("Chat", $"{configKey}.VIPs", false)) return true; - string[] whitelist = AppConfig!.Get("Chat", $"{configKey}.Whitelist")!.ToLower().Split(','); + string[] whitelist = AppConfig!.Get("Chat", $"{configKey}.Whitelist")!.ToLower() + .Split(',').Select(s => s.Trim()).ToArray(); if (whitelist.Contains(user.ToLower().Trim())) return true; diff --git a/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs b/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs new file mode 100644 index 00000000..cd79e72c --- /dev/null +++ b/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs @@ -0,0 +1,411 @@ +using SubathonManager.Tests.Utility; + +namespace SubathonManager.Tests.CoreUnitTests; + +public class ResourcePathsPureTests +{ + + [Theory] + [InlineData("/resources/images/logo.png", true)] + [InlineData("/RESOURCES/images/logo.png", true)] + [InlineData("\\resources\\images\\logo.png", true)] + [InlineData("resources/images/logo.png", false)] + [InlineData("/res/images/logo.png", false)] + [InlineData("./resources/logo.png", false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData(null, false)] + public void IsResourceUrl_Branches(string? value, bool expected) + => Assert.Equal(expected, ResourcePaths.IsResourceUrl(value)); + + + [Theory] + [InlineData("/resources/images/logo.png", "images/logo.png")] + [InlineData("\\resources\\images\\logo.png", "images/logo.png")] + [InlineData("/resources/images/logo.png?v=2", "images/logo.png")] + [InlineData("/resources/images/logo.png#frag", "images/logo.png")] + [InlineData("/resources/images/logo.png?v=2#frag", "images/logo.png")] + [InlineData("/resources//images/logo.png/", "images/logo.png")] + public void RelativeFromUrl_StripsPrefixQueryAndFragment(string url, string expected) + => Assert.Equal(expected, ResourcePaths.RelativeFromUrl(url)); + + [Theory] + [InlineData("/resources/")] + [InlineData("/resources/?x=1")] + [InlineData("/notresources/logo.png")] + [InlineData(null)] + public void RelativeFromUrl_ReturnsNull_WhenNoUsableRelative(string? url) + => Assert.Null(ResourcePaths.RelativeFromUrl(url)); + + + [Theory] + [InlineData(null)] + [InlineData("")] + public void FindReferences_EmptyInput_YieldsNothing(string? text) + => Assert.Empty(ResourcePaths.FindReferences(text)); + + [Fact] + public void FindReferences_NoMatch_YieldsNothing() + => Assert.Empty(ResourcePaths.FindReferences("

nothing to see here

")); + + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("background: url(/resources/images/logo.png);")] + [InlineData("const a = `/resources/images/logo.png`;")] + [InlineData("let x = /resources/images/logo.png ")] + [InlineData("load(a,/resources/images/logo.png)")] + [InlineData("x;/resources/images/logo.png")] + [InlineData("[/resources/images/logo.png ")] + public void FindReferences_AcceptedDelimiters(string text) + => Assert.Equal(["images/logo.png"], ResourcePaths.FindReferences(text)); + + [Fact] + public void FindReferences_RejectsMidWordMatch() + { + Assert.Empty(ResourcePaths.FindReferences("https://cdn.example.com/resources/images/logo.png")); + } + + [Fact] + public void FindReferences_StripsQueryAndFragment() + { + Assert.Equal(new[] {"audio/ding.mp3"}, + ResourcePaths.FindReferences("
diff --git a/SubathonManager.UI/UiUtils/FileManagerFocus.cs b/SubathonManager.UI/UiUtils/FileManagerFocus.cs new file mode 100644 index 00000000..b66c721f --- /dev/null +++ b/SubathonManager.UI/UiUtils/FileManagerFocus.cs @@ -0,0 +1,183 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace SubathonManager.UI.UiUtils; + +public static partial class FileManagerFocus +{ + #region WINDOWS + + private const int SwRestore = 9; + + [DllImport("user32")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32")] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32")] + private static extern bool IsIconic(IntPtr hWnd); + + [SupportedOSPlatform("windows")] + public static bool TryFocusExplorer(string? folder, string? selectFile = null) + { + if (string.IsNullOrWhiteSpace(folder)) return false; + + string target; + try { target = Path.GetFullPath(folder).TrimEnd(Path.DirectorySeparatorChar); } + catch { return false; } + + object? shell = null; + object? windows = null; + + try + { + var shellType = Type.GetTypeFromProgID("Shell.Application"); + if (shellType == null) return false; + + shell = Activator.CreateInstance(shellType); + if (shell == null) return false; + + windows = Invoke(shell, "Windows"); + if (windows == null) return false; + + if (Invoke(windows, "Count") is not int count) return false; + + for (int i = 0; i < count; i++) + { + object? window = null; + try + { + window = Invoke(windows, "Item", i); + if (window == null) continue; + + object? document = GetProperty(window, "Document"); + string? path = GetProperty(GetProperty(GetProperty(document, "Folder"), "Self"), "Path") as string; + if (string.IsNullOrWhiteSpace(path)) continue; + + if (!string.Equals(path.TrimEnd(Path.DirectorySeparatorChar), target, + StringComparison.OrdinalIgnoreCase)) + continue; + + if (GetProperty(window, "HWND") is not { } hwndValue) continue; + + var hwnd = new IntPtr(Convert.ToInt64(hwndValue)); + if (hwnd == IntPtr.Zero) continue; + + if (IsIconic(hwnd)) ShowWindow(hwnd, SwRestore); + SetForegroundWindow(hwnd); + + TrySelectItem(document, selectFile); + return true; + } + catch {/**/} + finally + { + Release(window); + } + } + } + catch + { + return false; + } + finally + { + Release(windows); + Release(shell); + } + + return false; + } + + private static void TrySelectItem(object? document, string? selectFile) + { + if (document == null || string.IsNullOrWhiteSpace(selectFile)) return; + + try + { + object? folder = GetProperty(document, "Folder"); + if (folder == null) return; + + object? item = Invoke(folder, "ParseName", Path.GetFileName(selectFile)); + if (item == null) return; + + // SVSI_SELECT | SVSI_DESELECTOTHERS | SVSI_ENSUREVISIBLE | SVSI_FOCUSED + Invoke(document, "SelectItem", item, 0x0001 | 0x0004 | 0x0008 | 0x0010); + } + catch { /**/ } + } + + private static object? Invoke(object target, string name, params object?[] args) + => target.GetType().InvokeMember(name, BindingFlags.InvokeMethod | BindingFlags.GetProperty, + null, target, args); + + private static object? GetProperty(object? target, string name) + => target?.GetType().InvokeMember(name, BindingFlags.GetProperty, null, target, null); + + private static void Release(object? comObject) + { + try + { + if (comObject != null && Marshal.IsComObject(comObject)) +#pragma warning disable CA1416 + Marshal.ReleaseComObject(comObject); +#pragma warning restore CA1416 + } + catch { /**/ } + } + + #endregion + + #region LINUX + + public static bool TryShowItemsOverDBus(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + + string uri; + try { uri = new Uri(Path.GetFullPath(path)).AbsoluteUri; } + catch { return false; } + + try + { + using var process = Process.Start(new ProcessStartInfo("gdbus") + { + ArgumentList = + { + "call", "--session", + "--dest", "org.freedesktop.FileManager1", + "--object-path", "/org/freedesktop/FileManager1", + "--method", "org.freedesktop.FileManager1.ShowItems", + $"['{EscapeGVariant(uri)}']", + "" + }, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }); + + if (process == null) return false; + + if (!process.WaitForExit(3000)) + { + try { process.Kill(entireProcessTree: true); } catch { /**/ } + return false; + } + + return process.ExitCode == 0; + } + catch + { + return false; + } + } + + private static string EscapeGVariant(string value) + => value.Replace("\\", "\\\\").Replace("'", "\\'"); + + #endregion +} diff --git a/SubathonManager.UI/UiUtils/UiHelpers.cs b/SubathonManager.UI/UiUtils/UiHelpers.cs index acf22d60..2dea0ca3 100644 --- a/SubathonManager.UI/UiUtils/UiHelpers.cs +++ b/SubathonManager.UI/UiUtils/UiHelpers.cs @@ -85,11 +85,17 @@ public static bool RevealInFileManager(string? path) { // diff actions so not calling raw open folder, doesn't matter for linux if (OperatingSystem.IsWindows()) + { + if (FileManagerFocus.TryFocusExplorer(Path.GetDirectoryName(path), path)) return true; Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true }); + } else if (OperatingSystem.IsMacOS()) Process.Start(new ProcessStartInfo("open") { ArgumentList = { "-R", path } }); else + { + if (FileManagerFocus.TryShowItemsOverDBus(path)) return true; return OpenFolder(Path.GetDirectoryName(path)); + } return true; } catch @@ -104,7 +110,10 @@ public static bool OpenFolder(string? dir) try { if (OperatingSystem.IsWindows()) + { + if (FileManagerFocus.TryFocusExplorer(dir)) return true; Process.Start(new ProcessStartInfo("explorer.exe", $"\"{dir}\"") { UseShellExecute = true }); + } else if (OperatingSystem.IsMacOS()) Process.Start(new ProcessStartInfo("open") { ArgumentList = { dir } }); else 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 @@ - + + diff --git a/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs b/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs index f51c7e7d..370681c3 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs +++ b/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs @@ -62,6 +62,7 @@ public WheelEditor() SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; WheelEvents.OnSpinsOwedUpdateFromEvent += AdjustSpinsBoxByEvent; WheelEvents.WheelSpinStatusChanged += OnWheelSpinStatusChanged; + RefreshSpinsOwed(); }; Unloaded += (_, _) => { @@ -820,6 +821,16 @@ private void SpinDelayBox_LostFocus(object? sender, RoutedEventArgs e) SuppressChanges(() => SpinDelayBox.Text = delay.ToString()); } + private void RefreshSpinsOwed() + { + using var db = _factory.CreateDbContext(); + int stored = StateValueHelper.Get(db, StateKeys.WheelSpinsOwed); + if (stored == _spinsOwed) return; + _spinsOwed = stored; + SuppressChanges(() => SpinsOwedBox.Text = stored.ToString()); + RaiseWheelDataChanged(); + } + private void AdjustSpinsBoxByEvent(int val) { Dispatcher.UIThread.Post(() => @@ -1511,6 +1522,33 @@ private async void ExportHistory_Click(object? sender, RoutedEventArgs e) catch { /**/ } } + private async void DeleteAllSpinHistory_Click(object? sender, RoutedEventArgs e) + { + if (_activeWheel == null) return; + var wheelId = _activeWheel.Id; + + var dialog = new FAContentDialog + { + Title = "Delete Spin History", + PrimaryButtonText = "Delete", + CloseButtonText = "Cancel", + Content = new TextBlock + { + Text = $"Are you sure you want to delete all spin history for \"{_activeWheel.Name}\"?", + TextWrapping = TextWrapping.Wrap, + Width = 320, + Margin = new global::Avalonia.Thickness(4) + } + }; + + if (await dialog.ShowAsync() != FAContentDialogResult.Primary) return; + + await using var db = await _factory.CreateDbContextAsync(); + await db.WheelSpinHistories.Where(h => h.WheelId == wheelId).ExecuteDeleteAsync(); + + await Dispatcher.UIThread.InvokeAsync(async () => await LoadHistoryAsync()); + } + private async void ExportWheel_Click(object? sender, RoutedEventArgs e) { if (_activeWheel == null) return; @@ -1525,7 +1563,7 @@ private async void ExportWheel_Click(object? sender, RoutedEventArgs e) string exportDir = Path.Combine(Config.DataFolder, "exports"); Directory.CreateDirectory(exportDir); - string safeName = string.Concat(wheel.Name.Split(Path.GetInvalidFileNameChars())); + string safeName = SafeFileName.Sanitize(wheel.Name, replacement: string.Empty, fallback: "wheel"); string timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); string filepath = Path.Combine(exportDir, $"{safeName}-{timestamp}.csv"); diff --git a/SubathonManager.UI/Views/WidgetBrowserDialog.axaml b/SubathonManager.UI/Views/WidgetBrowserDialog.axaml new file mode 100644 index 00000000..0cacbcb4 --- /dev/null +++ b/SubathonManager.UI/Views/WidgetBrowserDialog.axaml @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - diff --git a/SubathonManager.UI/Views/WidgetBrowserDialog.axaml.cs b/SubathonManager.UI/Views/WidgetBrowserDialog.axaml.cs index 9c690efa..680acad9 100644 --- a/SubathonManager.UI/Views/WidgetBrowserDialog.axaml.cs +++ b/SubathonManager.UI/Views/WidgetBrowserDialog.axaml.cs @@ -40,6 +40,7 @@ public WidgetBrowserDialog() : this(null) { } public WidgetBrowserDialog(Func>? onAdd) { InitializeComponent(); + UiUtils.WindowIcons.Apply(this); SectionsList.ItemsSource = _sections; _onAdd = onAdd; @@ -259,6 +260,22 @@ private void AllVersionsCheck_Changed(object? sender, RoutedEventArgs e) private void Close_Click(object? sender, RoutedEventArgs e) => Close(); + private void OpenCommunityWidgets_Click(object? sender, RoutedEventArgs e) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "https://docs.subathonmanager.app/latest/browse-widgets/", + UseShellExecute = true + }); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to open the widgets browser page"); + } + } + private void SetStatus(string text) => StatusText.Text = text; #endregion