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/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/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..cf2acca --- /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) + .AddSingleton(clock) + .AddSingleton() + .AddSingleton(settingsStore) + .AddSingleton(snipStore) + .AddSingleton(backupService) + .AddSingleton(config) + .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..19c6be6 100644 --- a/src/Snipdeck.App/MainWindow.xaml.cs +++ b/src/Snipdeck.App/MainWindow.xaml.cs @@ -1,12 +1,35 @@ 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, + ThemePreference.System => ElementTheme.Default, + _ => ElementTheme.Default, + }; + } } } } diff --git a/src/Snipdeck.App/Program.cs b/src/Snipdeck.App/Program.cs new file mode 100644 index 0000000..2a9a9f7 --- /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(p => + { + 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..cde1d76 --- /dev/null +++ b/src/Snipdeck.App/Services/WinUiDispatcher.cs @@ -0,0 +1,39 @@ +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; + + public bool HasUiThreadAccess => GetQueue().HasThreadAccess; + + public void Enqueue(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + var queue = GetQueue(); + if (queue.HasThreadAccess) + { + action(); + return; + } + + _ = 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/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..9ebd395 100644 --- a/src/Snipdeck.App/Snipdeck.App.csproj +++ b/src/Snipdeck.App/Snipdeck.App.csproj @@ -13,6 +13,16 @@ None true x64 + + false + $(DefineConstants);DISABLE_XAML_GENERATED_MAIN + + true @@ -40,6 +50,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; } + } +}