Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<ItemGroup>
<PackageVersion Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.251219" />
<PackageVersion Include="H.NotifyIcon.WinUI" Version="2.4.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
<PackageVersion Include="Velopack" Version="1.0.1" />
Expand Down
52 changes: 52 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 42 additions & 4 deletions src/Snipdeck.App/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -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<IDispatcher>();
_ = 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>();
_mainWindow.Activate();
}

private static async Task SeedFirstRunIfEmptyAsync()
{
var store = Services.GetRequiredService<ISnipStore>();
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<IDispatcher>();
dispatcher.Enqueue(() =>
{
_mainWindow?.Activate();
});
}
}
}
48 changes: 48 additions & 0 deletions src/Snipdeck.App/Bootstrap.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Builds the dependency-injection container. Settings are loaded
/// synchronously here so the snip-store and backup paths can be resolved
/// from <see cref="AppConfig"/> before the rest of the graph is built.
/// </summary>
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<IPathProvider>(pathProvider)
.AddSingleton<IClock>(clock)
.AddSingleton<IDispatcher, WinUiDispatcher>()
.AddSingleton<ISettingsStore>(settingsStore)
.AddSingleton<ISnipStore>(snipStore)
.AddSingleton<IBackupService>(backupService)
.AddSingleton(config)
.AddSingleton<MainWindow>();

return services.BuildServiceProvider();
}
}
}
25 changes: 23 additions & 2 deletions src/Snipdeck.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -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">

<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>

<Grid>
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>

<Border x:Name="AppTitleBar"
Grid.Row="0"
Height="40"
Padding="12,0,0,0">
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center">
<TextBlock x:Name="AppTitleTextBlock"
Text="Snipdeck"
Style="{StaticResource CaptionTextBlockStyle}"
VerticalAlignment="Center" />
</StackPanel>
</Border>

<Grid Grid.Row="1"
Padding="24">
<!-- Phase 3 fills this in with the NavigationView shell. -->
</Grid>
</Grid>
</Window>
25 changes: 24 additions & 1 deletion src/Snipdeck.App/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
}
}
61 changes: 61 additions & 0 deletions src/Snipdeck.App/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.Windows.AppLifecycle;

using Velopack;

namespace Snipdeck.App
{
/// <summary>
/// Explicit entry point. The boot order is load-bearing:
/// <list type="number">
/// <item>Velopack — must run first so it can intercept install / update / uninstall invocations.</item>
/// <item>WinRT COM wrappers init.</item>
/// <item>Single-instance check + activation redirect.</item>
/// <item>UI start (DI container + main window).</item>
/// </list>
/// </summary>
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;
}
}
}
39 changes: 39 additions & 0 deletions src/Snipdeck.App/Services/WinUiDispatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Microsoft.UI.Dispatching;

using Snipdeck.Core.Abstractions;

namespace Snipdeck.App.Services
{
/// <summary>
/// Captures the UI thread's <see cref="DispatcherQueue"/> 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).
/// </summary>
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.");
}
}
}
Loading
Loading