From 77a46a16866e5d82f2122ced68fad0e0983773a5 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 16:12:49 +0000 Subject: [PATCH 1/4] Phase 2 app lifecycle skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit Program.cs Main: Velopack hook → ComWrappersSupport.InitializeComWrappers → single-instance check via Windows App SDK AppInstance.FindOrRegisterForKey ("snipdeck") with activation redirect to the primary → Application.Start with a DispatcherQueueSynchronizationContext. Disables the XAML-generated Main so this order is load-bearing. Bootstrap.cs builds the DI container (Microsoft.Extensions.DependencyInjection 10.0.8). Settings are loaded synchronously up front so the snip-store and backup paths can be resolved (config value or path-provider default) before the rest of the graph is built. New abstractions in Core: IPathProvider and IDispatcher. New App-side services: - WindowsPathProvider: paths rooted at %LOCALAPPDATA%\Snipdeck. - WinUiDispatcher: lazy-captures the UI thread's DispatcherQueue on first use; App constructor warms it up so the secondary-instance activation handler can marshal back to the UI thread safely. MainWindow: - Mica backdrop (already on the scaffolded XAML). - ExtendsContentIntoTitleBar + custom draggable title bar showing the app name. - Theme applied from AppConfig (Light / Dark / System). App.xaml.cs: - Owns the static IServiceProvider. - Runs ExamplesSeed.Build() into the snip store on first run (empty store). - Subscribes to AppInstance.Activated; a redirect from a secondary instance foregrounds the existing main window. Core tests still pass (63/63). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 23 +++++++ Directory.Packages.props | 1 + src/Snipdeck.App/App.xaml.cs | 46 ++++++++++++-- src/Snipdeck.App/Bootstrap.cs | 48 +++++++++++++++ src/Snipdeck.App/MainWindow.xaml | 25 +++++++- src/Snipdeck.App/MainWindow.xaml.cs | 24 +++++++- src/Snipdeck.App/Program.cs | 61 +++++++++++++++++++ src/Snipdeck.App/Services/WinUiDispatcher.cs | 36 +++++++++++ .../Services/WindowsPathProvider.cs | 33 ++++++++++ src/Snipdeck.App/Snipdeck.App.csproj | 4 ++ src/Snipdeck.Core/Abstractions/IDispatcher.cs | 13 ++++ .../Abstractions/IPathProvider.cs | 13 ++++ 12 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 src/Snipdeck.App/Bootstrap.cs create mode 100644 src/Snipdeck.App/Program.cs create mode 100644 src/Snipdeck.App/Services/WinUiDispatcher.cs create mode 100644 src/Snipdeck.App/Services/WindowsPathProvider.cs create mode 100644 src/Snipdeck.Core/Abstractions/IDispatcher.cs create mode 100644 src/Snipdeck.Core/Abstractions/IPathProvider.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index bb317dc..687d41b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — Phase 2: App lifecycle skeleton +- Explicit `Program.cs` entry point that runs the Velopack hook → initialises + WinRT COM wrappers → checks single-instance via Windows App SDK + `AppInstance.FindOrRegisterForKey("snipdeck")` and redirects activation to + the primary instance → starts WinUI with a `DispatcherQueueSynchronizationContext`. +- `Bootstrap.cs` builds the DI container (`Microsoft.Extensions.DependencyInjection`): + loads `AppConfig` from the settings store synchronously, resolves the + snip-store and backup directories (config value or default), and registers + every Core service plus `MainWindow`. +- App-side platform services: `WindowsPathProvider` (paths rooted at + `%LOCALAPPDATA%\Snipdeck`), `WinUiDispatcher` (lazy-captures the UI thread's + `DispatcherQueue` on first use). +- Core abstractions: `IPathProvider`, `IDispatcher`. +- `MainWindow` shell: Mica backdrop, `ExtendsContentIntoTitleBar` with a custom + draggable title bar showing the app name, theme application from `AppConfig` + (Light / Dark / System). +- First-run seed: if the snip store is empty when the app launches, the + Examples CLI is written via `ISnipStore` before the window appears. +- Activation redirect from a secondary instance brings the primary instance's + window back to the foreground. +- `Microsoft.Extensions.DependencyInjection` (10.0.8) added to centralised + package versions. + ### Added - Repository scaffold: `Snipdeck.Core` (net10.0, UI-free), `Snipdeck.App` (WinUI 3, net10.0-windows), `Snipdeck.Core.Tests` (xUnit). diff --git a/Directory.Packages.props b/Directory.Packages.props index 036aa81..e61fc2a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/src/Snipdeck.App/App.xaml.cs b/src/Snipdeck.App/App.xaml.cs index 188f05b..27854cd 100644 --- a/src/Snipdeck.App/App.xaml.cs +++ b/src/Snipdeck.App/App.xaml.cs @@ -1,20 +1,58 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; +using Microsoft.Windows.AppLifecycle; + +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Services; namespace Snipdeck.App { public partial class App : Application { - private Window? _window; + private Window? _mainWindow; public App() { InitializeComponent(); + Services = Bootstrap.Build(); + + // Warm up the dispatcher on the UI thread so the activation-redirect + // handler can safely marshal back here from a worker thread. + var dispatcher = Services.GetRequiredService(); + _ = dispatcher.HasUiThreadAccess; + + AppInstance.GetCurrent().Activated += OnInstanceActivated; + } + + public static IServiceProvider Services { get; private set; } = null!; + + protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + { + ArgumentNullException.ThrowIfNull(args); + + await SeedFirstRunIfEmptyAsync(); + + _mainWindow = Services.GetRequiredService(); + _mainWindow.Activate(); + } + + private static async Task SeedFirstRunIfEmptyAsync() + { + var store = Services.GetRequiredService(); + var document = await store.LoadAsync(); + if (ExamplesSeed.IsEmpty(document)) + { + await store.SaveAsync(ExamplesSeed.Build()); + } } - protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + private void OnInstanceActivated(object? sender, AppActivationArguments e) { - _window = new MainWindow(); - _window.Activate(); + var dispatcher = Services.GetRequiredService(); + dispatcher.Enqueue(() => + { + _mainWindow?.Activate(); + }); } } } diff --git a/src/Snipdeck.App/Bootstrap.cs b/src/Snipdeck.App/Bootstrap.cs new file mode 100644 index 0000000..208ac7e --- /dev/null +++ b/src/Snipdeck.App/Bootstrap.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.DependencyInjection; + +using Snipdeck.App.Services; +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; +using Snipdeck.Core.Services; + +namespace Snipdeck.App +{ + /// + /// Builds the dependency-injection container. Settings are loaded + /// synchronously here so the snip-store and backup paths can be resolved + /// from before the rest of the graph is built. + /// + internal static class Bootstrap + { + private const string _snipStoreFileName = "store.json"; + + public static IServiceProvider Build() + { + var pathProvider = new WindowsPathProvider(); + var clock = new SystemClock(); + var settingsStore = new JsonSettingsStore(pathProvider.SettingsFilePath); + + var config = settingsStore.LoadAsync().GetAwaiter().GetResult(); + + var storageDirectory = config.StoragePath ?? pathProvider.DefaultStorageDirectory; + var backupDirectory = config.BackupDirectory ?? pathProvider.DefaultBackupDirectory; + var snipStoreFilePath = Path.Combine(storageDirectory, _snipStoreFileName); + + var snipStore = new JsonSnipStore(snipStoreFilePath); + var backupService = new BackupService(snipStoreFilePath, backupDirectory, clock); + + var services = new ServiceCollection(); + services.AddSingleton(pathProvider); + services.AddSingleton(clock); + services.AddSingleton(); + services.AddSingleton(settingsStore); + services.AddSingleton(snipStore); + services.AddSingleton(backupService); + services.AddSingleton(config); + + services.AddSingleton(); + + return services.BuildServiceProvider(); + } + } +} diff --git a/src/Snipdeck.App/MainWindow.xaml b/src/Snipdeck.App/MainWindow.xaml index 8d9c067..2450ae0 100644 --- a/src/Snipdeck.App/MainWindow.xaml +++ b/src/Snipdeck.App/MainWindow.xaml @@ -7,13 +7,34 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" - Title="Snipdeck.App"> + Title="Snipdeck"> - + + + + + + + + + + + + + + diff --git a/src/Snipdeck.App/MainWindow.xaml.cs b/src/Snipdeck.App/MainWindow.xaml.cs index a573380..01bb72b 100644 --- a/src/Snipdeck.App/MainWindow.xaml.cs +++ b/src/Snipdeck.App/MainWindow.xaml.cs @@ -1,12 +1,34 @@ using Microsoft.UI.Xaml; +using Snipdeck.Core.Models; + namespace Snipdeck.App { public sealed partial class MainWindow : Window { - public MainWindow() + public MainWindow(AppConfig config) { + ArgumentNullException.ThrowIfNull(config); + InitializeComponent(); + + ExtendsContentIntoTitleBar = true; + SetTitleBar(AppTitleBar); + + ApplyTheme(config.Theme); + } + + private void ApplyTheme(ThemePreference theme) + { + if (Content is FrameworkElement root) + { + root.RequestedTheme = theme switch + { + ThemePreference.Light => ElementTheme.Light, + ThemePreference.Dark => ElementTheme.Dark, + _ => ElementTheme.Default, + }; + } } } } diff --git a/src/Snipdeck.App/Program.cs b/src/Snipdeck.App/Program.cs new file mode 100644 index 0000000..4fc3369 --- /dev/null +++ b/src/Snipdeck.App/Program.cs @@ -0,0 +1,61 @@ +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.Windows.AppLifecycle; + +using Velopack; + +namespace Snipdeck.App +{ + /// + /// Explicit entry point. The boot order is load-bearing: + /// + /// Velopack — must run first so it can intercept install / update / uninstall invocations. + /// WinRT COM wrappers init. + /// Single-instance check + activation redirect. + /// UI start (DI container + main window). + /// + /// + public static class Program + { + private const string _singleInstanceKey = "snipdeck"; + + [STAThread] + public static int Main(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + + VelopackApp.Build().Run(); + + WinRT.ComWrappersSupport.InitializeComWrappers(); + + if (RedirectActivationIfSecondaryInstance()) + { + return 0; + } + + Application.Start(_ => + { + var context = new DispatcherQueueSynchronizationContext( + DispatcherQueue.GetForCurrentThread()); + SynchronizationContext.SetSynchronizationContext(context); + _ = new App(); + }); + + return 0; + } + + private static bool RedirectActivationIfSecondaryInstance() + { + var activation = AppInstance.GetCurrent().GetActivatedEventArgs(); + var keyInstance = AppInstance.FindOrRegisterForKey(_singleInstanceKey); + + if (keyInstance.IsCurrent) + { + return false; + } + + keyInstance.RedirectActivationToAsync(activation).AsTask().Wait(); + return true; + } + } +} diff --git a/src/Snipdeck.App/Services/WinUiDispatcher.cs b/src/Snipdeck.App/Services/WinUiDispatcher.cs new file mode 100644 index 0000000..f441d9f --- /dev/null +++ b/src/Snipdeck.App/Services/WinUiDispatcher.cs @@ -0,0 +1,36 @@ +using Microsoft.UI.Dispatching; + +using Snipdeck.Core.Abstractions; + +namespace Snipdeck.App.Services +{ + /// + /// Captures the UI thread's the first time it + /// is resolved on the UI thread; that means the very first call must come + /// from the main thread (the App startup path satisfies this). + /// + internal sealed class WinUiDispatcher : IDispatcher + { + private DispatcherQueue? _dispatcherQueue; + + private DispatcherQueue Queue => + _dispatcherQueue ??= DispatcherQueue.GetForCurrentThread() + ?? throw new InvalidOperationException( + "WinUiDispatcher was first resolved off the UI thread; no DispatcherQueue is available."); + + public bool HasUiThreadAccess => Queue.HasThreadAccess; + + public void Enqueue(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (Queue.HasThreadAccess) + { + action(); + return; + } + + _ = Queue.TryEnqueue(() => action()); + } + } +} diff --git a/src/Snipdeck.App/Services/WindowsPathProvider.cs b/src/Snipdeck.App/Services/WindowsPathProvider.cs new file mode 100644 index 0000000..5b1a788 --- /dev/null +++ b/src/Snipdeck.App/Services/WindowsPathProvider.cs @@ -0,0 +1,33 @@ +using Snipdeck.Core.Abstractions; + +namespace Snipdeck.App.Services +{ + /// + /// Resolves Snipdeck's data paths under %LOCALAPPDATA%\Snipdeck. + /// + internal sealed class WindowsPathProvider : IPathProvider + { + private const string _appFolderName = "Snipdeck"; + private const string _settingsFileName = "settings.json"; + private const string _storeDirectoryName = "store"; + private const string _backupsDirectoryName = "backups"; + + public WindowsPathProvider() + { + AppDataDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + _appFolderName); + SettingsFilePath = Path.Combine(AppDataDirectory, _settingsFileName); + DefaultStorageDirectory = Path.Combine(AppDataDirectory, _storeDirectoryName); + DefaultBackupDirectory = Path.Combine(AppDataDirectory, _backupsDirectoryName); + } + + public string AppDataDirectory { get; } + + public string SettingsFilePath { get; } + + public string DefaultStorageDirectory { get; } + + public string DefaultBackupDirectory { get; } + } +} diff --git a/src/Snipdeck.App/Snipdeck.App.csproj b/src/Snipdeck.App/Snipdeck.App.csproj index 41cabc9..e8858ed 100644 --- a/src/Snipdeck.App/Snipdeck.App.csproj +++ b/src/Snipdeck.App/Snipdeck.App.csproj @@ -13,6 +13,9 @@ None true x64 + + false @@ -40,6 +43,7 @@ + diff --git a/src/Snipdeck.Core/Abstractions/IDispatcher.cs b/src/Snipdeck.Core/Abstractions/IDispatcher.cs new file mode 100644 index 0000000..0624e69 --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/IDispatcher.cs @@ -0,0 +1,13 @@ +namespace Snipdeck.Core.Abstractions +{ + /// + /// Abstracts marshalling work back to the UI thread so view models in Core + /// can post updates without referencing WinUI types directly. + /// + public interface IDispatcher + { + bool HasUiThreadAccess { get; } + + void Enqueue(Action action); + } +} diff --git a/src/Snipdeck.Core/Abstractions/IPathProvider.cs b/src/Snipdeck.Core/Abstractions/IPathProvider.cs new file mode 100644 index 0000000..e04b101 --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/IPathProvider.cs @@ -0,0 +1,13 @@ +namespace Snipdeck.Core.Abstractions +{ + public interface IPathProvider + { + string AppDataDirectory { get; } + + string SettingsFilePath { get; } + + string DefaultStorageDirectory { get; } + + string DefaultBackupDirectory { get; } + } +} From d413e529e51c41c349b2d2cbb68c6baa97d3991c Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 16:17:09 +0000 Subject: [PATCH 2/4] Define DISABLE_XAML_GENERATED_MAIN to actually suppress the auto-Main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnableDefaultXamlGeneratedMain=false alone doesn't propagate to the WinUI XAML compiler in WindowsAppSDK 2.1.0 — it still emits the Program class with its own Main, colliding with Program.cs. The compiler wraps that Program in #if !DISABLE_XAML_GENERATED_MAIN, so define that constant explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Snipdeck.App/Snipdeck.App.csproj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Snipdeck.App/Snipdeck.App.csproj b/src/Snipdeck.App/Snipdeck.App.csproj index e8858ed..89b72e9 100644 --- a/src/Snipdeck.App/Snipdeck.App.csproj +++ b/src/Snipdeck.App/Snipdeck.App.csproj @@ -14,8 +14,12 @@ true x64 + XAML-generated Main, so Velopack + single-instance run first. + EnableDefaultXamlGeneratedMain doesn't suppress the generated + Main in WindowsAppSDK 2.1.0 — the auto-generated Program class + is gated on the C# define instead, so set both. --> false + $(DefineConstants);DISABLE_XAML_GENERATED_MAIN From eb04df3c0e03437f0b5d09727faba2e140d5fe4e Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 16:22:09 +0000 Subject: [PATCH 3/4] Fix Application.Start lambda + add TODO.md for shared parameter definitions Program.cs used '_' as the lambda parameter, which made '_ = new App()' inside the lambda assign App to the parameter (typed as ApplicationInitializationCallbackParams) instead of being a discard. Rename the parameter to 'p' so the discard works as intended. TODO.md is a new backlog file. Seeded with the shared-parameter-definitions idea: today every Snip carries its parameter definitions inline (env, region, etc.), and the same definition gets duplicated across many Snips. Sketches a shared-by-name model at both CLI and global scope, with local overrides on the Snip, and notes the open design questions. Co-Authored-By: Claude Opus 4.7 (1M context) --- TODO.md | 52 +++++++++++++++++++++++++++++++++++++ src/Snipdeck.App/Program.cs | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..b4e3cf9 --- /dev/null +++ b/TODO.md @@ -0,0 +1,52 @@ +# TODO + +A backlog of ideas worth building but not yet scheduled. Not a commitment — the +canonical list of *parked* v1 features is the "Out of scope for v1" section in +[CLAUDE.md](CLAUDE.md). + +--- + +## Shared parameter definitions (global and CLI-scoped) + +**Problem.** Today every `Snip` carries its own `Parameter` definitions inline. +If twenty Snips all need an `env` Choice dropdown with options +`dev` / `staging` / `prod`, that definition is duplicated twenty times and the +user has to keep them in sync by hand. Same story for any other recurring +token (`region`, `org_id`, `tenant`, etc.). + +**Idea.** Let parameter definitions live above the Snip and be *referenced* by +name from individual Snips, with the option to still define a parameter +locally on a Snip when something one-off is needed. + +**Sketch.** +- Add a collection of shared `Parameter` definitions, probably at two scopes: + - **CLI-scoped** — most common case, e.g. an `env` defined once on the + `pl-app` CLI and used by every `pl-app` Snip. + - **Global** — for the rare cross-CLI definition (`yes_no`, common flags). + - Both scopes should be supported; CLI-scoped takes precedence over global + when names collide. +- A Snip can either: + - **Reference** a shared parameter by name — it picks up the type, options, + and default automatically. + - **Define** a parameter locally — overrides any shared definition with the + same name *for that Snip only*. +- The substitution engine doesn't change: tokens still resolve from a + `name → value` dictionary. Only the *definition* moves; resolution is the + same. + +**Open questions** to settle when this gets scheduled: +- Reference by **name** or by **ID**? Names match the `{token}` model and read + well; IDs survive renames. Probably name-based with a uniqueness constraint + per scope, plus a rename flow that propagates. +- Where does the UI to manage shared parameters live? A Settings page? A + flyout from each CLI in the pane header? Probably the latter for CLI-scoped + and a Settings entry for global. +- Schema migration: additive. New collections on `SnipStoreDocument` (global) + and `Cli` (CLI-scoped). Existing Snips with local `Parameter` entries keep + working unchanged. +- Should a Snip's `Parameters` list contain a discriminated union (local + definition vs. reference) or two parallel lists? A small object with + `Name` + optional inline definition feels cleanest — absent inline ⇒ resolve + from shared scope. + +This is the single biggest content-quality-of-life feature after v1 ships. diff --git a/src/Snipdeck.App/Program.cs b/src/Snipdeck.App/Program.cs index 4fc3369..2a9a9f7 100644 --- a/src/Snipdeck.App/Program.cs +++ b/src/Snipdeck.App/Program.cs @@ -33,7 +33,7 @@ public static int Main(string[] args) return 0; } - Application.Start(_ => + Application.Start(p => { var context = new DispatcherQueueSynchronizationContext( DispatcherQueue.GetForCurrentThread()); From 0b0fb036b8a0a868a4107924b891b24e3f81e7a1 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 16:25:35 +0000 Subject: [PATCH 4/4] Address WinUI analyser fallout under TreatWarningsAsErrors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AllowUnsafeBlocks=true: CsWinRT1030 requires it so the generators can emit the marshalling glue when generic interface types (IServiceProvider, ServiceProvider) cross the WinRT ABI. - Bootstrap: chain the AddSingleton calls to consume the IServiceCollection return value (IDE0058) — clean fluent style, also fewer lines. - MainWindow.ApplyTheme: list ThemePreference.System explicitly so the switch is exhaustive (IDE0072) and keep the underscore arm for defence in depth. - WinUiDispatcher: lazy-init via a GetQueue() method instead of a Queue property; the analyser misreads the '??=' property as 'use auto-property' (IDE0032). A method sidesteps it without changing the semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Snipdeck.App/Bootstrap.cs | 18 +++++++++--------- src/Snipdeck.App/MainWindow.xaml.cs | 1 + src/Snipdeck.App/Services/WinUiDispatcher.cs | 19 +++++++++++-------- src/Snipdeck.App/Snipdeck.App.csproj | 3 +++ 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/Snipdeck.App/Bootstrap.cs b/src/Snipdeck.App/Bootstrap.cs index 208ac7e..cf2acca 100644 --- a/src/Snipdeck.App/Bootstrap.cs +++ b/src/Snipdeck.App/Bootstrap.cs @@ -32,15 +32,15 @@ public static IServiceProvider Build() var backupService = new BackupService(snipStoreFilePath, backupDirectory, clock); var services = new ServiceCollection(); - services.AddSingleton(pathProvider); - services.AddSingleton(clock); - services.AddSingleton(); - services.AddSingleton(settingsStore); - services.AddSingleton(snipStore); - services.AddSingleton(backupService); - services.AddSingleton(config); - - services.AddSingleton(); + _ = services + .AddSingleton(pathProvider) + .AddSingleton(clock) + .AddSingleton() + .AddSingleton(settingsStore) + .AddSingleton(snipStore) + .AddSingleton(backupService) + .AddSingleton(config) + .AddSingleton(); return services.BuildServiceProvider(); } diff --git a/src/Snipdeck.App/MainWindow.xaml.cs b/src/Snipdeck.App/MainWindow.xaml.cs index 01bb72b..19c6be6 100644 --- a/src/Snipdeck.App/MainWindow.xaml.cs +++ b/src/Snipdeck.App/MainWindow.xaml.cs @@ -26,6 +26,7 @@ private void ApplyTheme(ThemePreference theme) { ThemePreference.Light => ElementTheme.Light, ThemePreference.Dark => ElementTheme.Dark, + ThemePreference.System => ElementTheme.Default, _ => ElementTheme.Default, }; } diff --git a/src/Snipdeck.App/Services/WinUiDispatcher.cs b/src/Snipdeck.App/Services/WinUiDispatcher.cs index f441d9f..cde1d76 100644 --- a/src/Snipdeck.App/Services/WinUiDispatcher.cs +++ b/src/Snipdeck.App/Services/WinUiDispatcher.cs @@ -13,24 +13,27 @@ internal sealed class WinUiDispatcher : IDispatcher { private DispatcherQueue? _dispatcherQueue; - private DispatcherQueue Queue => - _dispatcherQueue ??= DispatcherQueue.GetForCurrentThread() - ?? throw new InvalidOperationException( - "WinUiDispatcher was first resolved off the UI thread; no DispatcherQueue is available."); - - public bool HasUiThreadAccess => Queue.HasThreadAccess; + public bool HasUiThreadAccess => GetQueue().HasThreadAccess; public void Enqueue(Action action) { ArgumentNullException.ThrowIfNull(action); - if (Queue.HasThreadAccess) + var queue = GetQueue(); + if (queue.HasThreadAccess) { action(); return; } - _ = Queue.TryEnqueue(() => action()); + _ = queue.TryEnqueue(() => action()); + } + + private DispatcherQueue GetQueue() + { + return _dispatcherQueue ??= DispatcherQueue.GetForCurrentThread() + ?? throw new InvalidOperationException( + "WinUiDispatcher was first resolved off the UI thread; no DispatcherQueue is available."); } } } diff --git a/src/Snipdeck.App/Snipdeck.App.csproj b/src/Snipdeck.App/Snipdeck.App.csproj index 89b72e9..9ebd395 100644 --- a/src/Snipdeck.App/Snipdeck.App.csproj +++ b/src/Snipdeck.App/Snipdeck.App.csproj @@ -20,6 +20,9 @@ is gated on the C# define instead, so set both. --> false $(DefineConstants);DISABLE_XAML_GENERATED_MAIN + + true