diff --git a/samples/AppKit.Sample.WinUI/App.xaml.cs b/samples/AppKit.Sample.WinUI/App.xaml.cs
index 72706f2..5525c29 100644
--- a/samples/AppKit.Sample.WinUI/App.xaml.cs
+++ b/samples/AppKit.Sample.WinUI/App.xaml.cs
@@ -10,6 +10,7 @@ using Microsoft.UI.Xaml;
namespace AppKit.Sample.WinUI;
+/// The WinUI sample application: installs AppKit's crash net and the saved theme, then opens the main window.
public partial class App : Application
{
private Window? _window;
@@ -19,6 +20,7 @@ public partial class App : Application
+ /// Installs the crash net and applies the saved theme; WinUI allows setting the application theme only here, before any window exists.
public App()
{
InitializeComponent();
@@ -29,6 +31,7 @@ public partial class App : Application
+ /// Logs the start and opens the main window.
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
Log.Info($"{SampleApp.Info.DisplayName} started.");
diff --git a/samples/AppKit.Sample.WinUI/AppKit.Sample.WinUI.csproj b/samples/AppKit.Sample.WinUI/AppKit.Sample.WinUI.csproj
index c485ea7..19b27ab 100644
--- a/samples/AppKit.Sample.WinUI/AppKit.Sample.WinUI.csproj
+++ b/samples/AppKit.Sample.WinUI/AppKit.Sample.WinUI.csproj
@@ -20,7 +20,7 @@
-
+
diff --git a/samples/AppKit.Sample.WinUI/MainWindow.xaml.cs b/samples/AppKit.Sample.WinUI/MainWindow.xaml.cs
index d4ce797..fa66971 100644
--- a/samples/AppKit.Sample.WinUI/MainWindow.xaml.cs
+++ b/samples/AppKit.Sample.WinUI/MainWindow.xaml.cs
@@ -11,6 +11,7 @@ using Microsoft.UI.Xaml;
namespace AppKit.Sample.WinUI;
+/// The sample's main window: each control exercises one AppKit service.
public sealed partial class MainWindow : Window
{
private readonly SampleSettings _settings = SampleApp.SettingsStore.Load();
@@ -18,6 +19,7 @@ public sealed partial class MainWindow : Window
+ /// Sizes and brands the window, wires the update coordinator, restores the saved settings, shows the splash, and schedules the quiet startup update check.
public MainWindow()
{
InitializeComponent();
@@ -41,20 +43,31 @@ public sealed partial class MainWindow : Window
if(_settings.CheckForUpdatesOnStartup)
{
- DispatcherQueue.TryEnqueue(async () =>
- {
- await Task.Delay(_settings.ShowSplashScreen ? _settings.SplashMilliseconds + 500 : 1000);
- await _updates.CheckQuietlyAsync();
- });
+ // The constructor runs on the UI thread, so the check resumes there to show dialogs.
+ _ = CheckForUpdatesAfterStartupAsync();
}
}
+ ///
+ /// Waits for the splash to clear (dialogs need the window's XamlRoot), then runs the
+ /// quiet startup update check, which only speaks up when an update exists.
+ ///
+ private async Task CheckForUpdatesAfterStartupAsync()
+ {
+ await Task.Delay(_settings.ShowSplashScreen ? _settings.SplashMilliseconds + 500 : 1000);
+ await _updates.CheckQuietlyAsync();
+ }
+
+
+
+ /// File, Exit.
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
+ /// View, theme: applies the chosen theme to the window and saves it.
private void Theme_Click(object sender, RoutedEventArgs e)
{
_settings.Theme = (string)((FrameworkElement)sender).Tag;
@@ -64,6 +77,7 @@ public sealed partial class MainWindow : Window
+ /// View, Show splash at startup: saves the preference.
private void Splash_Click(object sender, RoutedEventArgs e)
{
_settings.ShowSplashScreen = SplashToggle.IsChecked;
@@ -72,18 +86,22 @@ public sealed partial class MainWindow : Window
+ /// Help, Check for updates: runs the interactive update flow.
private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) => await _updates.CheckInteractivelyAsync();
+ /// Help, Activity log: opens the live log window, or brings the open one forward.
private void ActivityLog_Click(object sender, RoutedEventArgs e) => LogWindow.ShowSingle(App.Log, SampleApp.Info);
+ /// Help, About: shows the About dialog.
private async void About_Click(object sender, RoutedEventArgs e) => await AboutDialog.ShowAsync(SampleApp.Info, Content.XamlRoot);
+ /// Writes an INFO entry, to show logging.
private void LogInfo_Click(object sender, RoutedEventArgs e)
{
App.Log.Info("The user pressed 'Write a log entry'.");
@@ -92,6 +110,7 @@ public sealed partial class MainWindow : Window
+ /// Logs a handled exception with its stack trace, to show error logging.
private void LogError_Click(object sender, RoutedEventArgs e)
{
try
@@ -107,6 +126,7 @@ public sealed partial class MainWindow : Window
+ /// Throws on purpose, to show that the crash net logs the exception and the app keeps running.
private void Throw_Click(object sender, RoutedEventArgs e)
{
StatusText.Text = "Threw; the crash net logged it and the app kept running.";
@@ -115,6 +135,7 @@ public sealed partial class MainWindow : Window
+ /// Saves the settings and reports a failed save in the status line.
private void Save()
{
if(!SampleApp.SettingsStore.Save(_settings))
diff --git a/samples/AppKit.Sample.WinUI/Program.cs b/samples/AppKit.Sample.WinUI/Program.cs
index 570efe2..ec08886 100644
--- a/samples/AppKit.Sample.WinUI/Program.cs
+++ b/samples/AppKit.Sample.WinUI/Program.cs
@@ -15,6 +15,7 @@ namespace AppKit.Sample.WinUI;
///
public static class Program
{
+ /// Runs Velopack's install and update hooks first, then starts the WinUI application (the XAML-generated Main is disabled for this).
[STAThread]
private static void Main()
{
diff --git a/samples/AppKit.Sample.Wpf/AppKit.Sample.Wpf.csproj b/samples/AppKit.Sample.Wpf/AppKit.Sample.Wpf.csproj
index d7023d4..5c7a69c 100644
--- a/samples/AppKit.Sample.Wpf/AppKit.Sample.Wpf.csproj
+++ b/samples/AppKit.Sample.Wpf/AppKit.Sample.Wpf.csproj
@@ -15,7 +15,7 @@
-
+
diff --git a/samples/AppKit.Sample.Wpf/MainWindow.xaml.cs b/samples/AppKit.Sample.Wpf/MainWindow.xaml.cs
index e3b23a6..39319ea 100644
--- a/samples/AppKit.Sample.Wpf/MainWindow.xaml.cs
+++ b/samples/AppKit.Sample.Wpf/MainWindow.xaml.cs
@@ -11,6 +11,7 @@ using KofTwentyTwo.AppKit.Wpf;
namespace AppKit.Sample.Wpf;
+/// The sample's main window: each control exercises one AppKit service.
public partial class MainWindow : Window
{
private readonly SampleSettings _settings = SampleApp.SettingsStore.Load();
@@ -18,6 +19,7 @@ public partial class MainWindow : Window
+ /// Brands the window, wires the update coordinator, restores the saved settings, shows the splash, and schedules the quiet startup update check.
public MainWindow()
{
InitializeComponent();
@@ -49,10 +51,12 @@ public partial class MainWindow : Window
+ /// File, Exit.
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
+ /// View, theme: applies the chosen Fluent theme to the application and saves it.
private void Theme_Click(object sender, RoutedEventArgs e)
{
_settings.Theme = (string)((FrameworkElement)sender).Tag;
@@ -63,6 +67,7 @@ public partial class MainWindow : Window
+ /// View, Show splash at startup: saves the preference.
private void Splash_Click(object sender, RoutedEventArgs e)
{
_settings.ShowSplashScreen = SplashToggle.IsChecked;
@@ -71,18 +76,22 @@ public partial class MainWindow : Window
+ /// Help, Check for updates: runs the interactive update flow.
private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) => await _updates.CheckInteractivelyAsync();
+ /// Help, Activity log: opens the live log window, or brings the open one forward.
private void ActivityLog_Click(object sender, RoutedEventArgs e) => LogWindow.ShowSingle(Program.Log, SampleApp.Info);
+ /// Help, About: shows the About window.
private void About_Click(object sender, RoutedEventArgs e) => AboutWindow.Show(SampleApp.Info, this);
+ /// Writes an INFO entry, to show logging.
private void LogInfo_Click(object sender, RoutedEventArgs e)
{
Program.Log.Info("The user pressed 'Write a log entry'.");
@@ -91,6 +100,7 @@ public partial class MainWindow : Window
+ /// Logs a handled exception with its stack trace, to show error logging.
private void LogError_Click(object sender, RoutedEventArgs e)
{
try
@@ -106,6 +116,7 @@ public partial class MainWindow : Window
+ /// Throws on purpose, to show that the crash net logs the exception and the app keeps running.
private void Throw_Click(object sender, RoutedEventArgs e)
{
StatusText.Text = "Threw; the crash net logged it and the app kept running.";
@@ -114,6 +125,7 @@ public partial class MainWindow : Window
+ /// Checks the View menu item that matches the saved theme and clears the others.
private void CheckTheme()
{
foreach(MenuItem item in new[] { ThemeSystem, ThemeLight, ThemeDark })
@@ -124,6 +136,7 @@ public partial class MainWindow : Window
+ /// Saves the settings and reports a failed save in the status line.
private void Save()
{
if(!SampleApp.SettingsStore.Save(_settings))
diff --git a/samples/AppKit.Sample.Wpf/Program.cs b/samples/AppKit.Sample.Wpf/Program.cs
index 7dab033..fba4d59 100644
--- a/samples/AppKit.Sample.Wpf/Program.cs
+++ b/samples/AppKit.Sample.Wpf/Program.cs
@@ -19,6 +19,7 @@ public static class Program
+ /// Runs Velopack's install and update hooks first, then creates the WPF application with AppKit's crash net and theme and shows the main window.
[STAThread]
private static void Main()
{
diff --git a/samples/Shared/SampleApp.cs b/samples/Shared/SampleApp.cs
index 6e37494..bcf393c 100644
--- a/samples/Shared/SampleApp.cs
+++ b/samples/Shared/SampleApp.cs
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: MIT
*/
-using System.Text.Json.Serialization;
using KofTwentyTwo.AppKit;
using KofTwentyTwo.AppKit.Settings;
@@ -26,11 +25,11 @@ internal static class SampleApp
Copyright = "Copyright (c) 2026 James Maes",
Brand = new AppBrand { IconSvgPath = "Assets/Brand/app-icon.svg", IconIcoPath = "Assets/Brand/app.ico" },
Attributions =
- [
- new("KofTwentyTwo.AppKit", "MIT License"),
- new("Velopack", "MIT License"),
- new(".NET Runtime", "MIT License"),
- ],
+ [
+ new("KofTwentyTwo.AppKit", "MIT License"),
+ new("Velopack", "MIT License"),
+ new(".NET Runtime", "MIT License"),
+ ],
};
@@ -39,27 +38,3 @@ internal static class SampleApp
public static SettingsStore SettingsStore { get; } = new(Paths.SettingsFile, SampleSettingsContext.Default.SampleSettings);
}
-
-
-
-/// The sample's preferences: the shared shell settings plus one of its own.
-internal sealed class SampleSettings : ShellSettings
-{
- public string Greeting { get; set; } = "Hello from AppKit";
-
-
-
- public override void Sanitize()
- {
- base.Sanitize();
- Greeting ??= "Hello from AppKit";
- }
-}
-
-
-
-[JsonSourceGenerationOptions(WriteIndented = true)]
-[JsonSerializable(typeof(SampleSettings))]
-internal sealed partial class SampleSettingsContext : JsonSerializerContext
-{
-}
diff --git a/samples/Shared/SampleSettings.cs b/samples/Shared/SampleSettings.cs
new file mode 100644
index 0000000..b01d4ac
--- /dev/null
+++ b/samples/Shared/SampleSettings.cs
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+using KofTwentyTwo.AppKit.Settings;
+
+
+namespace AppKit.Sample;
+
+/// The sample's preferences: the shared shell settings plus one of its own.
+internal sealed class SampleSettings : ShellSettings
+{
+ public string Greeting { get; set; } = "Hello from AppKit";
+
+
+
+ /// Repairs the shared shell settings, then restores a missing greeting.
+ public override void Sanitize()
+ {
+ base.Sanitize();
+ Greeting ??= "Hello from AppKit";
+ }
+}
diff --git a/samples/Shared/SampleSettingsContext.cs b/samples/Shared/SampleSettingsContext.cs
new file mode 100644
index 0000000..4bff1f3
--- /dev/null
+++ b/samples/Shared/SampleSettingsContext.cs
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+using System.Text.Json.Serialization;
+
+
+namespace AppKit.Sample;
+
+/// Source-generated JSON serializer for the sample's settings, so trimmed builds need no reflection.
+[JsonSourceGenerationOptions(WriteIndented = true)]
+[JsonSerializable(typeof(SampleSettings))]
+internal sealed partial class SampleSettingsContext : JsonSerializerContext
+{
+}
diff --git a/src/KofTwentyTwo.AppKit.Updates/IUpdateBackend.cs b/src/KofTwentyTwo.AppKit.Updates/IUpdateBackend.cs
new file mode 100644
index 0000000..0aa8a06
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit.Updates/IUpdateBackend.cs
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+namespace KofTwentyTwo.AppKit.Updates;
+
+///
+/// The narrow slice of Velopack the update service uses, so the guarding and state
+/// logic is testable without an installed app or the network.
+///
+internal interface IUpdateBackend
+{
+ bool IsInstalled { get; }
+
+ string? CurrentVersion { get; }
+
+
+
+ /// The newer version found (and remembered for download), or null.
+ Task CheckAsync();
+
+
+
+ /// Downloads the remembered update.
+ Task DownloadAsync(Action? progress, CancellationToken cancellationToken);
+
+
+
+ /// Applies the downloaded update and restarts; exits the process on success.
+ void ApplyAndRestart();
+}
diff --git a/src/KofTwentyTwo.AppKit.Updates/IUpdateService.cs b/src/KofTwentyTwo.AppKit.Updates/IUpdateService.cs
index 711e88b..e50074d 100644
--- a/src/KofTwentyTwo.AppKit.Updates/IUpdateService.cs
+++ b/src/KofTwentyTwo.AppKit.Updates/IUpdateService.cs
@@ -5,32 +5,6 @@
namespace KofTwentyTwo.AppKit.Updates;
-///
-/// Result of an update check. is the newer version
-/// found, or null when already up to date; is non-null when the
-/// check itself failed.
-///
-public sealed record UpdateCheckResult(string? AvailableVersion, string? Error)
-{
- /// No newer version exists.
- public static UpdateCheckResult UpToDate { get; } = new(null, null);
-
- /// True when a newer version was found.
- public bool IsUpdateAvailable => Error is null && AvailableVersion is not null;
-
-
-
- /// A newer version was found.
- public static UpdateCheckResult Available(string version) => new(version, null);
-
-
-
- /// The check failed.
- public static UpdateCheckResult Failed(string error) => new(null, error);
-}
-
-
-
///
/// Checks for and installs app updates. Every member is guarded: failures come back as
/// values, never exceptions, because nothing as optional as an update may crash the app.
diff --git a/src/KofTwentyTwo.AppKit.Updates/UpdateCheckResult.cs b/src/KofTwentyTwo.AppKit.Updates/UpdateCheckResult.cs
new file mode 100644
index 0000000..75890e1
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit.Updates/UpdateCheckResult.cs
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+namespace KofTwentyTwo.AppKit.Updates;
+
+///
+/// Result of an update check. is the newer version
+/// found, or null when already up to date; is non-null when the
+/// check itself failed.
+///
+public sealed record UpdateCheckResult(string? AvailableVersion, string? Error)
+{
+ /// No newer version exists.
+ public static UpdateCheckResult UpToDate { get; } = new(null, null);
+
+ /// True when a newer version was found.
+ public bool IsUpdateAvailable => Error is null && AvailableVersion is not null;
+
+
+
+ /// A newer version was found.
+ public static UpdateCheckResult Available(string version) => new(version, null);
+
+
+
+ /// The check failed.
+ public static UpdateCheckResult Failed(string error) => new(null, error);
+}
diff --git a/src/KofTwentyTwo.AppKit.Updates/UpdateCoordinator.cs b/src/KofTwentyTwo.AppKit.Updates/UpdateCoordinator.cs
index 7be88f4..86dccae 100644
--- a/src/KofTwentyTwo.AppKit.Updates/UpdateCoordinator.cs
+++ b/src/KofTwentyTwo.AppKit.Updates/UpdateCoordinator.cs
@@ -9,47 +9,6 @@ using KofTwentyTwo.AppKit.Logging;
namespace KofTwentyTwo.AppKit.Updates;
-/// User-facing text of the update flow, shared by every UI package.
-public static class UpdateText
-{
- /// Title of the check dialogs.
- public const string CheckTitle = "Check for updates";
-
- /// Title of the confirmation dialog.
- public const string AvailableTitle = "Update available";
-
- /// Title shown when installing failed.
- public const string FailedTitle = "Update failed";
-
- /// Shown in builds that cannot self-update.
- public const string NotSupported = "Updates are only available in installed builds.";
-
- /// Confirm button.
- public const string Confirm = "Update and restart";
-
- /// Decline button.
- public const string Decline = "Not now";
-
-
-
- /// "You are up to date (v1.2.3)."
- public static string UpToDate(string? currentVersion)
- => currentVersion is null ? "You are up to date." : $"You are up to date (v{currentVersion}).";
-
-
-
- /// Explains a failed check.
- public static string CheckFailed(string error) => $"Could not check for updates.\n{error}";
-
-
-
- /// Offers the update.
- public static string Available(string appName, string version)
- => $"{appName} v{version} is available. The app will restart to finish installing the update.";
-}
-
-
-
///
/// The "check for updates" flows, written once against and
/// so WinUI and WPF apps behave identically and the logic is
@@ -140,6 +99,7 @@ public sealed class UpdateCoordinator
+ /// Offers the update and, when accepted, downloads it, applies it, and restarts. A failed install is logged and shown, because reaching the end means the restart did not happen.
private async Task OfferAsync(string version, CancellationToken cancellationToken)
{
bool accepted = await _prompter.ConfirmAsync(
diff --git a/src/KofTwentyTwo.AppKit.Updates/UpdateText.cs b/src/KofTwentyTwo.AppKit.Updates/UpdateText.cs
new file mode 100644
index 0000000..b717425
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit.Updates/UpdateText.cs
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+namespace KofTwentyTwo.AppKit.Updates;
+
+/// User-facing text of the update flow, shared by every UI package.
+public static class UpdateText
+{
+ /// Title of the check dialogs.
+ public const string CheckTitle = "Check for updates";
+
+ /// Title of the confirmation dialog.
+ public const string AvailableTitle = "Update available";
+
+ /// Title shown when installing failed.
+ public const string FailedTitle = "Update failed";
+
+ /// Shown in builds that cannot self-update.
+ public const string NotSupported = "Updates are only available in installed builds.";
+
+ /// Confirm button.
+ public const string Confirm = "Update and restart";
+
+ /// Decline button.
+ public const string Decline = "Not now";
+
+
+
+ /// "You are up to date (v1.2.3)."
+ public static string UpToDate(string? currentVersion)
+ => currentVersion is null ? "You are up to date." : $"You are up to date (v{currentVersion}).";
+
+
+
+ /// Explains a failed check.
+ public static string CheckFailed(string error) => $"Could not check for updates.\n{error}";
+
+
+
+ /// Offers the update.
+ public static string Available(string appName, string version)
+ => $"{appName} v{version} is available. The app will restart to finish installing the update.";
+}
diff --git a/src/KofTwentyTwo.AppKit.Updates/VelopackGithubBackend.cs b/src/KofTwentyTwo.AppKit.Updates/VelopackGithubBackend.cs
index 1e5db20..fb500e7 100644
--- a/src/KofTwentyTwo.AppKit.Updates/VelopackGithubBackend.cs
+++ b/src/KofTwentyTwo.AppKit.Updates/VelopackGithubBackend.cs
@@ -24,6 +24,7 @@ internal sealed class VelopackGithubBackend : IUpdateBackend
+ /// Creates a Velopack UpdateManager over the repository's GitHub Releases, looking at prereleases only when asked.
public VelopackGithubBackend(Uri repositoryUrl, bool includePrereleases)
{
_manager = new UpdateManager(new GithubSource(repositoryUrl.ToString(), accessToken: null, prerelease: includePrereleases));
@@ -37,6 +38,7 @@ internal sealed class VelopackGithubBackend : IUpdateBackend
+ /// Asks Velopack for a newer release and remembers it for the download.
public async Task CheckAsync()
{
_pending = await _manager.CheckForUpdatesAsync().ConfigureAwait(false);
@@ -45,6 +47,7 @@ internal sealed class VelopackGithubBackend : IUpdateBackend
+ /// Downloads the remembered update with progress; fails when no update was found.
public Task DownloadAsync(Action? progress, CancellationToken cancellationToken)
=> _pending is null
? Task.FromException(new InvalidOperationException("No update was found to download."))
@@ -52,6 +55,7 @@ internal sealed class VelopackGithubBackend : IUpdateBackend
+ /// Applies the downloaded update and restarts the app; Velopack exits the process on success.
public void ApplyAndRestart()
{
if(_pending is null)
diff --git a/src/KofTwentyTwo.AppKit.Updates/VelopackUpdateService.cs b/src/KofTwentyTwo.AppKit.Updates/VelopackUpdateService.cs
index 24413d3..d067ce2 100644
--- a/src/KofTwentyTwo.AppKit.Updates/VelopackUpdateService.cs
+++ b/src/KofTwentyTwo.AppKit.Updates/VelopackUpdateService.cs
@@ -8,34 +8,6 @@ using System.Reflection;
namespace KofTwentyTwo.AppKit.Updates;
-///
-/// The narrow slice of Velopack the update service uses, so the guarding and state
-/// logic is testable without an installed app or the network.
-///
-internal interface IUpdateBackend
-{
- bool IsInstalled { get; }
-
- string? CurrentVersion { get; }
-
-
-
- /// The newer version found (and remembered for download), or null.
- Task CheckAsync();
-
-
-
- /// Downloads the remembered update.
- Task DownloadAsync(Action? progress, CancellationToken cancellationToken);
-
-
-
- /// Applies the downloaded update and restarts; exits the process on success.
- void ApplyAndRestart();
-}
-
-
-
///
/// Self-update via Velopack with releases hosted on the app's public GitHub repository.
/// A prerelease build (a '-' in its version) follows the dev channel, whose packages
diff --git a/src/KofTwentyTwo.AppKit.WinUI/AboutDialog.cs b/src/KofTwentyTwo.AppKit.WinUI/AboutDialog.cs
index 552982c..20a8457 100644
--- a/src/KofTwentyTwo.AppKit.WinUI/AboutDialog.cs
+++ b/src/KofTwentyTwo.AppKit.WinUI/AboutDialog.cs
@@ -102,6 +102,7 @@ public sealed partial class AboutDialog : ContentDialog
+ /// A titled section of the dialog body: a bold heading the caller adds content lines under.
private static StackPanel Section(string heading)
{
var section = new StackPanel { Spacing = 4 };
@@ -111,6 +112,7 @@ public sealed partial class AboutDialog : ContentDialog
+ /// Looks up a theme style by key; null when the app's resources lack it, so the text falls back to default styling instead of failing.
private static Style? ThemeStyle(string key)
=> Application.Current.Resources.TryGetValue(key, out object? style) ? style as Style : null;
}
diff --git a/src/KofTwentyTwo.AppKit.WinUI/ContentDialogPrompter.cs b/src/KofTwentyTwo.AppKit.WinUI/ContentDialogPrompter.cs
new file mode 100644
index 0000000..8586369
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit.WinUI/ContentDialogPrompter.cs
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+using KofTwentyTwo.AppKit.Interaction;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+
+namespace KofTwentyTwo.AppKit.WinUI;
+
+///
+/// over guarded ContentDialogs, for shared flows such as
+/// .
+///
+/// Supplies the XamlRoot to host dialogs, usually () => window.Content.XamlRoot.
+public sealed class ContentDialogPrompter(Func xamlRoot) : IUserPrompter
+{
+ ///
+ public async Task ShowMessageAsync(string title, string message)
+ {
+ await DialogGuard.ShowAsync(new ContentDialog
+ {
+ Title = title,
+ Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
+ CloseButtonText = "OK",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = xamlRoot(),
+ });
+ }
+
+
+
+ ///
+ public async Task ConfirmAsync(string title, string message, string confirmText, string cancelText)
+ {
+ ContentDialogResult? result = await DialogGuard.ShowAsync(new ContentDialog
+ {
+ Title = title,
+ Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
+ PrimaryButtonText = confirmText,
+ CloseButtonText = cancelText,
+ DefaultButton = ContentDialogButton.Primary,
+ XamlRoot = xamlRoot(),
+ });
+ return result == ContentDialogResult.Primary;
+ }
+}
diff --git a/src/KofTwentyTwo.AppKit.WinUI/DialogGuard.cs b/src/KofTwentyTwo.AppKit.WinUI/DialogGuard.cs
index 760d6fb..bc1e81a 100644
--- a/src/KofTwentyTwo.AppKit.WinUI/DialogGuard.cs
+++ b/src/KofTwentyTwo.AppKit.WinUI/DialogGuard.cs
@@ -3,8 +3,6 @@
* SPDX-License-Identifier: MIT
*/
-using KofTwentyTwo.AppKit.Interaction;
-using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -52,43 +50,3 @@ public static class DialogGuard
}
}
}
-
-
-
-///
-/// over guarded ContentDialogs, for shared flows such as
-/// .
-///
-/// Supplies the XamlRoot to host dialogs, usually () => window.Content.XamlRoot.
-public sealed class ContentDialogPrompter(Func xamlRoot) : IUserPrompter
-{
- ///
- public async Task ShowMessageAsync(string title, string message)
- {
- await DialogGuard.ShowAsync(new ContentDialog
- {
- Title = title,
- Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
- CloseButtonText = "OK",
- DefaultButton = ContentDialogButton.Close,
- XamlRoot = xamlRoot(),
- });
- }
-
-
-
- ///
- public async Task ConfirmAsync(string title, string message, string confirmText, string cancelText)
- {
- ContentDialogResult? result = await DialogGuard.ShowAsync(new ContentDialog
- {
- Title = title,
- Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
- PrimaryButtonText = confirmText,
- CloseButtonText = cancelText,
- DefaultButton = ContentDialogButton.Primary,
- XamlRoot = xamlRoot(),
- });
- return result == ContentDialogResult.Primary;
- }
-}
diff --git a/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs b/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs
index 0efb48e..28f94b3 100644
--- a/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs
+++ b/src/KofTwentyTwo.AppKit.WinUI/LogWindow.cs
@@ -20,11 +20,11 @@ namespace KofTwentyTwo.AppKit.WinUI;
///
public sealed partial class LogWindow : Window
{
- private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(1);
+ private static readonly TimeSpan s_refreshInterval = TimeSpan.FromSeconds(1);
private static LogWindow? s_current;
private readonly IActivityLog _log;
- private readonly DispatcherTimer _refresh = new() { Interval = RefreshInterval };
+ private readonly DispatcherTimer _refresh = new() { Interval = s_refreshInterval };
private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7 };
private readonly TextBlock _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsTextSelectionEnabled = true, TextWrapping = TextWrapping.NoWrap };
private readonly ScrollViewer _scroll = new() { HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) };
@@ -105,6 +105,7 @@ public sealed partial class LogWindow : Window
+ /// Copies the visible log text to the clipboard.
private void CopyToClipboard()
{
var package = new Windows.ApplicationModel.DataTransfer.DataPackage();
@@ -139,6 +140,7 @@ public sealed partial class LogWindow : Window
+ /// Rereads the log tail, applies the errors-only filter, and scrolls to the newest entry.
private void Reload()
{
_pathText.Text = _log.CurrentLogFilePath; // the day can roll over while the window is open
diff --git a/src/KofTwentyTwo.AppKit.WinUI/SplashOverlay.cs b/src/KofTwentyTwo.AppKit.WinUI/SplashOverlay.cs
index fed155e..76f00fc 100644
--- a/src/KofTwentyTwo.AppKit.WinUI/SplashOverlay.cs
+++ b/src/KofTwentyTwo.AppKit.WinUI/SplashOverlay.cs
@@ -40,6 +40,29 @@ public sealed partial class SplashOverlay : UserControl
AutomationProperties.SetName(root, $"{app.DisplayName} is starting");
AutomationProperties.SetAutomationId(root, "AppKitSplash");
+ root.Children.Add(BuildCenter(app, versionAssembly ?? Assembly.GetEntryAssembly()));
+ if(BuildCredit(app) is { } credit)
+ {
+ root.Children.Add(credit);
+ }
+ root.Children.Add(BuildActivityStrip());
+
+ var fade = new DoubleAnimation { To = 0, Duration = new Duration(TimeSpan.FromMilliseconds(250)) };
+ Storyboard.SetTarget(fade, root);
+ Storyboard.SetTargetProperty(fade, "Opacity");
+ _fadeOut.Children.Add(fade);
+
+ Content = root;
+ }
+
+
+
+ ///
+ /// The centered brand block: the vector mark, the name, the tagline, and the build
+ /// identity of when there is one.
+ ///
+ private static StackPanel BuildCenter(AppInfo app, Assembly? assembly)
+ {
var center = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
center.Children.Add(new Image
{
@@ -58,37 +81,55 @@ public sealed partial class SplashOverlay : UserControl
tagline.Margin = new Thickness(0, 4, 0, 0);
AddCentered(center, tagline);
}
- Assembly? assembly = versionAssembly ?? Assembly.GetEntryAssembly();
if(assembly is not null)
{
TextBlock version = Brand.Text(BuildVersion.Describe(assembly), 12, 0.6);
version.Margin = new Thickness(0, 8, 0, 0);
AddCentered(center, version);
}
- root.Children.Add(center);
+ return center;
+ }
+
+
+ ///
+ /// The author credit anchored above the activity strip, or null when the app declares
+ /// neither an author nor any contact details.
+ ///
+ private static StackPanel? BuildCredit(AppInfo app)
+ {
string contact = Credits.ContactLine(app);
- if(app.Author.Length > 0 || contact.Length > 0)
+ if(app.Author.Length == 0 && contact.Length == 0)
{
- var credit = new StackPanel
- {
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Bottom,
- Margin = new Thickness(0, 0, 0, 44),
- Spacing = 3,
- };
- if(app.Author.Length > 0)
- {
- AddCentered(credit, Brand.Text("Created by " + app.Author, 13, 0.85, strong: true));
- }
- if(contact.Length > 0)
- {
- AddCentered(credit, Brand.Text(contact, 12, 0.6));
- }
- root.Children.Add(credit);
+ return null;
}
- // Thin activity strip: white bar over a 20% white track.
+ var credit = new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Margin = new Thickness(0, 0, 0, 44),
+ Spacing = 3,
+ };
+ if(app.Author.Length > 0)
+ {
+ AddCentered(credit, Brand.Text("Created by " + app.Author, 13, 0.85, strong: true));
+ }
+ if(contact.Length > 0)
+ {
+ AddCentered(credit, Brand.Text(contact, 12, 0.6));
+ }
+ return credit;
+ }
+
+
+
+ ///
+ /// The thin activity strip at the bottom: a white bar over a 20% white track, with the
+ /// track height resource overridden so the track matches the 3px bar.
+ ///
+ private static ProgressBar BuildActivityStrip()
+ {
var progress = new ProgressBar
{
IsIndeterminate = true,
@@ -100,14 +141,7 @@ public sealed partial class SplashOverlay : UserControl
Margin = new Thickness(0, 0, 0, 20),
};
progress.Resources["ProgressBarTrackHeight"] = 3.0;
- root.Children.Add(progress);
-
- var fade = new DoubleAnimation { To = 0, Duration = new Duration(TimeSpan.FromMilliseconds(250)) };
- Storyboard.SetTarget(fade, root);
- Storyboard.SetTargetProperty(fade, "Opacity");
- _fadeOut.Children.Add(fade);
-
- Content = root;
+ return progress;
}
@@ -133,16 +167,26 @@ public sealed partial class SplashOverlay : UserControl
Grid.SetColumnSpan(overlay, Math.Max(grid.ColumnDefinitions.Count, 1));
}
host.Children.Add(overlay);
- overlay.DispatcherQueue.TryEnqueue(async () =>
- {
- await Task.Delay(settings.SplashMilliseconds);
- await overlay.DismissAsync();
- });
+ ///////////////////////////////////////////////////////////////////////////////
+ // start the countdown through the dispatcher queue, not directly: the queue //
+ // runs it on the UI thread with its synchronization context in place, so //
+ // the fade after the delay also runs on the UI thread //
+ ///////////////////////////////////////////////////////////////////////////////
+ overlay.DispatcherQueue.TryEnqueue(() => _ = overlay.DismissAfterAsync(settings.SplashMilliseconds));
return overlay;
}
+ /// Waits , then fades the overlay out.
+ private async Task DismissAfterAsync(int milliseconds)
+ {
+ await Task.Delay(milliseconds);
+ await DismissAsync();
+ }
+
+
+
///
/// Fades the overlay out and removes it from its parent panel. Safe to call more
/// than once; only the first call does anything.
@@ -168,6 +212,7 @@ public sealed partial class SplashOverlay : UserControl
+ /// Centers an element horizontally and adds it to the panel.
private static void AddCentered(Panel panel, FrameworkElement element)
{
element.HorizontalAlignment = HorizontalAlignment.Center;
diff --git a/src/KofTwentyTwo.AppKit.WinUI/WinUIShell.cs b/src/KofTwentyTwo.AppKit.WinUI/WinUIShell.cs
index 57bb68f..23f5ac6 100644
--- a/src/KofTwentyTwo.AppKit.WinUI/WinUIShell.cs
+++ b/src/KofTwentyTwo.AppKit.WinUI/WinUIShell.cs
@@ -3,10 +3,8 @@
* SPDX-License-Identifier: MIT
*/
-using System.Runtime.InteropServices;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
-using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
@@ -75,65 +73,3 @@ public static class WinUIShell
};
}
}
-
-
-
-/// DPI-aware sizing and branding for WinUI windows.
-public static partial class WindowExtensions
-{
- /// The window's display scale (1.0 at 100%, 1.5 at 150%).
- public static double DpiScale(this Window window)
- {
- ArgumentNullException.ThrowIfNull(window);
- IntPtr hwnd = WinRT.Interop.WindowNative.GetWindowHandle(window);
- uint dpi = GetDpiForWindow(hwnd);
- return dpi == 0 ? 1.0 : dpi / 96.0;
- }
-
-
-
- ///
- /// Resizes to x effective pixels.
- /// AppWindow.Resize takes physical pixels, so this scales by the monitor DPI.
- ///
- public static void ResizeForDpi(this Window window, int width, int height)
- {
- double scale = window.DpiScale();
- window.AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(width * scale), (int)(height * scale)));
- }
-
-
-
- /// Keeps the window from shrinking below a usable size, in effective pixels.
- public static void SetMinimumSize(this Window window, int width, int height)
- {
- double scale = window.DpiScale();
- if(window.AppWindow.Presenter is OverlappedPresenter presenter)
- {
- presenter.PreferredMinimumWidth = (int)(width * scale);
- presenter.PreferredMinimumHeight = (int)(height * scale);
- }
- }
-
-
-
- ///
- /// Sets the brand icon on the title bar (and taskbar / Alt-Tab for unpackaged runs).
- /// The .ico must be copied to the output directory. A missing file is ignored.
- ///
- public static void SetAppIcon(this Window window, AppInfo app)
- {
- ArgumentNullException.ThrowIfNull(window);
- ArgumentNullException.ThrowIfNull(app);
- string path = Path.Combine(AppContext.BaseDirectory, app.Brand.IconIcoPath);
- if(File.Exists(path))
- {
- window.AppWindow.SetIcon(path);
- }
- }
-
-
-
- [LibraryImport("user32.dll")]
- private static partial uint GetDpiForWindow(IntPtr hwnd);
-}
diff --git a/src/KofTwentyTwo.AppKit.WinUI/WindowExtensions.cs b/src/KofTwentyTwo.AppKit.WinUI/WindowExtensions.cs
new file mode 100644
index 0000000..c6a2321
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit.WinUI/WindowExtensions.cs
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+using System.Runtime.InteropServices;
+using Microsoft.UI.Windowing;
+using Microsoft.UI.Xaml;
+
+
+namespace KofTwentyTwo.AppKit.WinUI;
+
+/// DPI-aware sizing and branding for WinUI windows.
+public static partial class WindowExtensions
+{
+ /// The window's display scale (1.0 at 100%, 1.5 at 150%).
+ public static double DpiScale(this Window window)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+ IntPtr hwnd = WinRT.Interop.WindowNative.GetWindowHandle(window);
+ uint dpi = GetDpiForWindow(hwnd);
+ return dpi == 0 ? 1.0 : dpi / 96.0;
+ }
+
+
+
+ ///
+ /// Resizes to x effective pixels.
+ /// AppWindow.Resize takes physical pixels, so this scales by the monitor DPI.
+ ///
+ public static void ResizeForDpi(this Window window, int width, int height)
+ {
+ double scale = window.DpiScale();
+ window.AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(width * scale), (int)(height * scale)));
+ }
+
+
+
+ /// Keeps the window from shrinking below a usable size, in effective pixels.
+ public static void SetMinimumSize(this Window window, int width, int height)
+ {
+ double scale = window.DpiScale();
+ if(window.AppWindow.Presenter is OverlappedPresenter presenter)
+ {
+ presenter.PreferredMinimumWidth = (int)(width * scale);
+ presenter.PreferredMinimumHeight = (int)(height * scale);
+ }
+ }
+
+
+
+ ///
+ /// Sets the brand icon on the title bar (and taskbar / Alt-Tab for unpackaged runs).
+ /// The .ico must be copied to the output directory. A missing file is ignored.
+ ///
+ public static void SetAppIcon(this Window window, AppInfo app)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+ ArgumentNullException.ThrowIfNull(app);
+ string path = Path.Combine(AppContext.BaseDirectory, app.Brand.IconIcoPath);
+ if(File.Exists(path))
+ {
+ window.AppWindow.SetIcon(path);
+ }
+ }
+
+
+
+ /// Win32 GetDpiForWindow: the effective DPI of the monitor the window is on.
+ [LibraryImport("user32.dll")]
+ private static partial uint GetDpiForWindow(IntPtr hwnd);
+}
diff --git a/src/KofTwentyTwo.AppKit.Wpf/AboutWindow.cs b/src/KofTwentyTwo.AppKit.Wpf/AboutWindow.cs
index 4b92992..5cf6414 100644
--- a/src/KofTwentyTwo.AppKit.Wpf/AboutWindow.cs
+++ b/src/KofTwentyTwo.AppKit.Wpf/AboutWindow.cs
@@ -83,6 +83,7 @@ public sealed class AboutWindow : Window
+ /// The brand header: the splash look in miniature, with the name, tagline, and build version.
private static Border Header(AppInfo app, Assembly? assembly)
{
var title = new StackPanel { Orientation = Orientation.Horizontal };
@@ -116,11 +117,13 @@ public sealed class AboutWindow : Window
+ /// A bold section heading.
private static TextBlock Heading(string text)
=> new() { Text = text, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 16, 0, 0) };
+ /// A wrapped paragraph with the given space above it.
private static TextBlock Paragraph(string text, double top = 12)
=> new() { Text = text, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, top, 0, 0) };
}
diff --git a/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs b/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs
index a3fe10c..1750be9 100644
--- a/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs
+++ b/src/KofTwentyTwo.AppKit.Wpf/LogWindow.cs
@@ -103,6 +103,7 @@ public sealed class LogWindow : Window
+ /// Timer tick: reloads only when Follow is on and the log file grew or rolled over to a new day.
private void RefreshIfChanged()
{
if(_follow.IsChecked != true)
@@ -127,6 +128,7 @@ public sealed class LogWindow : Window
+ /// Rereads the log tail, applies the errors-only filter, and scrolls to the newest entry.
private void Reload()
{
_pathText.Text = _log.CurrentLogFilePath;
diff --git a/src/KofTwentyTwo.AppKit.Wpf/MessageBoxPrompter.cs b/src/KofTwentyTwo.AppKit.Wpf/MessageBoxPrompter.cs
new file mode 100644
index 0000000..81a2ac6
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit.Wpf/MessageBoxPrompter.cs
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+using System.Windows;
+using KofTwentyTwo.AppKit.Interaction;
+
+
+namespace KofTwentyTwo.AppKit.Wpf;
+
+///
+/// over WPF message boxes, for shared flows such as
+/// . Message boxes cannot relabel their buttons,
+/// so confirmations show Yes/No with the confirm text folded into the question.
+///
+/// Supplies the owning window, usually the main window.
+public sealed class MessageBoxPrompter(Func owner) : IUserPrompter
+{
+ ///
+ public Task ShowMessageAsync(string title, string message)
+ {
+ Show(message, title, MessageBoxButton.OK, MessageBoxImage.Information);
+ return Task.CompletedTask;
+ }
+
+
+
+ ///
+ public Task ConfirmAsync(string title, string message, string confirmText, string cancelText)
+ {
+ MessageBoxResult result = Show($"{message}\n\n{confirmText}?", title, MessageBoxButton.YesNo, MessageBoxImage.Question);
+ return Task.FromResult(result == MessageBoxResult.Yes);
+ }
+
+
+
+ /// Shows a message box owned by the app's window when there is one, so it stays in front of the app.
+ private MessageBoxResult Show(string message, string title, MessageBoxButton buttons, MessageBoxImage image)
+ => owner() is Window window
+ ? MessageBox.Show(window, message, title, buttons, image)
+ : MessageBox.Show(message, title, buttons, image);
+}
diff --git a/src/KofTwentyTwo.AppKit.Wpf/SplashOverlay.cs b/src/KofTwentyTwo.AppKit.Wpf/SplashOverlay.cs
index 340923e..7fb03f1 100644
--- a/src/KofTwentyTwo.AppKit.Wpf/SplashOverlay.cs
+++ b/src/KofTwentyTwo.AppKit.Wpf/SplashOverlay.cs
@@ -111,16 +111,26 @@ public sealed class SplashOverlay : UserControl
Grid.SetColumnSpan(overlay, Math.Max(grid.ColumnDefinitions.Count, 1));
}
host.Children.Add(overlay);
- overlay.Dispatcher.InvokeAsync(async () =>
- {
- await Task.Delay(settings.SplashMilliseconds);
- await overlay.DismissAsync();
- });
+ ///////////////////////////////////////////////////////////////////////////////
+ // start the countdown through the dispatcher, not directly: an app may call //
+ // this before Application.Run, when no synchronization context exists yet, //
+ // and the fade must run on the UI thread //
+ ///////////////////////////////////////////////////////////////////////////////
+ _ = overlay.Dispatcher.InvokeAsync(() => overlay.DismissAfterAsync(settings.SplashMilliseconds));
return overlay;
}
+ /// Waits , then fades the overlay out.
+ private async Task DismissAfterAsync(int milliseconds)
+ {
+ await Task.Delay(milliseconds);
+ await DismissAsync();
+ }
+
+
+
///
/// Fades the overlay out and removes it from its parent panel. Safe to call more
/// than once; only the first call does anything.
diff --git a/src/KofTwentyTwo.AppKit.Wpf/WpfShell.cs b/src/KofTwentyTwo.AppKit.Wpf/WpfShell.cs
index 4718a0e..2aefdae 100644
--- a/src/KofTwentyTwo.AppKit.Wpf/WpfShell.cs
+++ b/src/KofTwentyTwo.AppKit.Wpf/WpfShell.cs
@@ -6,7 +6,6 @@
using System.IO;
using System.Windows;
using System.Windows.Media.Imaging;
-using KofTwentyTwo.AppKit.Interaction;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
@@ -71,6 +70,7 @@ public static class WpfShell
+ /// Records an exception caught by the crash net; a failure of the logger itself is swallowed so logging can never make an error fatal.
private static void SafeLog(Func log, string message, Exception exception)
{
try
@@ -83,37 +83,3 @@ public static class WpfShell
}
}
}
-
-
-
-///
-/// over WPF message boxes, for shared flows such as
-/// . Message boxes cannot relabel their buttons,
-/// so confirmations show Yes/No with the confirm text folded into the question.
-///
-/// Supplies the owning window, usually the main window.
-public sealed class MessageBoxPrompter(Func owner) : IUserPrompter
-{
- ///
- public Task ShowMessageAsync(string title, string message)
- {
- Show(message, title, MessageBoxButton.OK, MessageBoxImage.Information);
- return Task.CompletedTask;
- }
-
-
-
- ///
- public Task ConfirmAsync(string title, string message, string confirmText, string cancelText)
- {
- MessageBoxResult result = Show($"{message}\n\n{confirmText}?", title, MessageBoxButton.YesNo, MessageBoxImage.Question);
- return Task.FromResult(result == MessageBoxResult.Yes);
- }
-
-
-
- private MessageBoxResult Show(string message, string title, MessageBoxButton buttons, MessageBoxImage image)
- => owner() is Window window
- ? MessageBox.Show(window, message, title, buttons, image)
- : MessageBox.Show(message, title, buttons, image);
-}
diff --git a/src/KofTwentyTwo.AppKit/AppBrand.cs b/src/KofTwentyTwo.AppKit/AppBrand.cs
new file mode 100644
index 0000000..2734aa4
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit/AppBrand.cs
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+namespace KofTwentyTwo.AppKit;
+
+///
+/// Brand look for the splash and About header: a diagonal two-stop gradient behind
+/// white type, plus the app-relative paths of the vector mark and window icon.
+///
+public sealed record AppBrand
+{
+ /// Top-left gradient color, "#RRGGBB" or "#AARRGGBB".
+ public string GradientStart { get; init; } = "#3B4CCA";
+
+ /// Bottom-right gradient color, "#RRGGBB" or "#AARRGGBB".
+ public string GradientEnd { get; init; } = "#2A2F8F";
+
+ /// Vector mark, relative to the app directory.
+ public string IconSvgPath { get; init; } = "Assets/Brand/app-icon.svg";
+
+ /// Window and taskbar icon, relative to the app directory.
+ public string IconIcoPath { get; init; } = "Assets/Brand/app.ico";
+}
diff --git a/src/KofTwentyTwo.AppKit/AppInfo.cs b/src/KofTwentyTwo.AppKit/AppInfo.cs
index 5b54bcd..b2744ef 100644
--- a/src/KofTwentyTwo.AppKit/AppInfo.cs
+++ b/src/KofTwentyTwo.AppKit/AppInfo.cs
@@ -34,7 +34,7 @@ public sealed partial record AppInfo
{
throw new ArgumentException(
$"App id '{value}' is invalid: use lowercase letters, digits, and dashes, starting with a letter or digit.",
- nameof(Id));
+ nameof(value));
}
_id = value;
}
@@ -87,34 +87,7 @@ public sealed partial record AppInfo
- [GeneratedRegex("^[a-z0-9][a-z0-9-]*$")]
+ /// The app id grammar: lowercase letters, digits, and dashes, starting with a letter or digit. Source-generated, with a match timeout as a guard.
+ [GeneratedRegex("^[a-z0-9][a-z0-9-]*$", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
private static partial Regex IdPattern();
}
-
-
-
-/// A third-party component credited in the About screen.
-/// Component name, e.g. "Velopack".
-/// License name, e.g. "MIT License".
-public sealed record Attribution(string Name, string License);
-
-
-
-///
-/// Brand look for the splash and About header: a diagonal two-stop gradient behind
-/// white type, plus the app-relative paths of the vector mark and window icon.
-///
-public sealed record AppBrand
-{
- /// Top-left gradient color, "#RRGGBB" or "#AARRGGBB".
- public string GradientStart { get; init; } = "#3B4CCA";
-
- /// Bottom-right gradient color, "#RRGGBB" or "#AARRGGBB".
- public string GradientEnd { get; init; } = "#2A2F8F";
-
- /// Vector mark, relative to the app directory.
- public string IconSvgPath { get; init; } = "Assets/Brand/app-icon.svg";
-
- /// Window and taskbar icon, relative to the app directory.
- public string IconIcoPath { get; init; } = "Assets/Brand/app.ico";
-}
diff --git a/src/KofTwentyTwo.AppKit/Attribution.cs b/src/KofTwentyTwo.AppKit/Attribution.cs
new file mode 100644
index 0000000..91620a2
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit/Attribution.cs
@@ -0,0 +1,11 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+namespace KofTwentyTwo.AppKit;
+
+/// A third-party component credited in the About screen.
+/// Component name, e.g. "Velopack".
+/// License name, e.g. "MIT License".
+public sealed record Attribution(string Name, string License);
diff --git a/src/KofTwentyTwo.AppKit/BuildVersion.cs b/src/KofTwentyTwo.AppKit/BuildVersion.cs
index 48fd445..594aa7c 100644
--- a/src/KofTwentyTwo.AppKit/BuildVersion.cs
+++ b/src/KofTwentyTwo.AppKit/BuildVersion.cs
@@ -88,6 +88,7 @@ public static class BuildVersion
+ /// The informational version the build stamped (tag version plus the SourceLink commit), falling back to the assembly version.
private static string? InformationalVersion(Assembly assembly)
=> assembly.GetCustomAttribute()?.InformationalVersion
?? assembly.GetName().Version?.ToString();
diff --git a/src/KofTwentyTwo.AppKit/Logging/FileActivityLog.cs b/src/KofTwentyTwo.AppKit/Logging/FileActivityLog.cs
index 02215b3..59a417f 100644
--- a/src/KofTwentyTwo.AppKit/Logging/FileActivityLog.cs
+++ b/src/KofTwentyTwo.AppKit/Logging/FileActivityLog.cs
@@ -86,11 +86,13 @@ public sealed class FileActivityLog : IActivityLog
+ /// The log file for one day.
private string FilePathFor(DateOnly day)
=> Path.Combine(LogDirectory, $"{_filePrefix}-{day.ToString(DateFormat, CultureInfo.InvariantCulture)}.log");
+ /// Appends one entry under the lock, pruning old files on the first write of a day. Never throws: a logging failure must not take down the operation it describes.
private void Write(string level, string message, Exception? exception)
{
try
diff --git a/src/KofTwentyTwo.AppKit/Logging/IActivityLog.cs b/src/KofTwentyTwo.AppKit/Logging/IActivityLog.cs
index 73ee50f..a25b867 100644
--- a/src/KofTwentyTwo.AppKit/Logging/IActivityLog.cs
+++ b/src/KofTwentyTwo.AppKit/Logging/IActivityLog.cs
@@ -33,39 +33,3 @@ public interface IActivityLog
/// Full path of the file new entries are currently appended to.
string CurrentLogFilePath { get; }
}
-
-
-
-/// An that discards everything.
-public sealed class NullActivityLog : IActivityLog
-{
- /// The shared instance.
- public static NullActivityLog Instance { get; } = new();
-
- ///
- public string LogDirectory => "";
-
- ///
- public string CurrentLogFilePath => "";
-
-
-
- ///
- public void Info(string message)
- {
- }
-
-
-
- ///
- public void Warning(string message)
- {
- }
-
-
-
- ///
- public void Error(string message, Exception? exception = null)
- {
- }
-}
diff --git a/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs b/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs
new file mode 100644
index 0000000..17678e0
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit/Logging/LogSnapshot.cs
@@ -0,0 +1,11 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+namespace KofTwentyTwo.AppKit.Logging;
+
+/// The last lines of a log file and the file length they were read at.
+/// The tail, oldest first.
+/// File length in bytes when read; 0 when the file is absent.
+public sealed record LogSnapshot(IReadOnlyList Lines, long Length);
diff --git a/src/KofTwentyTwo.AppKit/Logging/LogTail.cs b/src/KofTwentyTwo.AppKit/Logging/LogTail.cs
index 9b86fd5..a3e4a9b 100644
--- a/src/KofTwentyTwo.AppKit/Logging/LogTail.cs
+++ b/src/KofTwentyTwo.AppKit/Logging/LogTail.cs
@@ -7,14 +7,6 @@ using System.Text.RegularExpressions;
namespace KofTwentyTwo.AppKit.Logging;
-
-/// The last lines of a log file and the file length they were read at.
-/// The tail, oldest first.
-/// File length in bytes when read; 0 when the file is absent.
-public sealed record LogSnapshot(IReadOnlyList Lines, long Length);
-
-
-
///
/// Reads and filters files for the log viewer windows.
/// Never throws: this is diagnostics UI, and an unreadable file becomes a readable
@@ -100,6 +92,7 @@ public static partial class LogTail
- [GeneratedRegex(@"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")]
+ /// Matches the timestamp that starts every log entry, so continuation lines (exception text) can be told apart.
+ [GeneratedRegex(@"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
private static partial Regex EntryStart();
}
diff --git a/src/KofTwentyTwo.AppKit/Logging/NullActivityLog.cs b/src/KofTwentyTwo.AppKit/Logging/NullActivityLog.cs
new file mode 100644
index 0000000..749f525
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit/Logging/NullActivityLog.cs
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+namespace KofTwentyTwo.AppKit.Logging;
+
+/// An that discards everything.
+public sealed class NullActivityLog : IActivityLog
+{
+ /// The shared instance.
+ public static NullActivityLog Instance { get; } = new();
+
+ ///
+ public string LogDirectory => "";
+
+ ///
+ public string CurrentLogFilePath => "";
+
+
+
+ ///
+ public void Info(string message)
+ {
+ }
+
+
+
+ ///
+ public void Warning(string message)
+ {
+ }
+
+
+
+ ///
+ public void Error(string message, Exception? exception = null)
+ {
+ }
+}
diff --git a/src/KofTwentyTwo.AppKit/Secrets/CredentialManagerVault.cs b/src/KofTwentyTwo.AppKit/Secrets/CredentialManagerVault.cs
index 4a616c4..0626d22 100644
--- a/src/KofTwentyTwo.AppKit/Secrets/CredentialManagerVault.cs
+++ b/src/KofTwentyTwo.AppKit/Secrets/CredentialManagerVault.cs
@@ -176,24 +176,28 @@ public sealed class CredentialManagerVault : ISecretVault
+ /// advapi32 CredWriteW: creates or replaces a credential.
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredWriteW(ref CredentialW credential, uint flags);
+ /// advapi32 CredReadW: reads a credential into a buffer that must be released with CredFree.
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredReadW(string targetName, uint type, uint flags, out IntPtr credential);
+ /// advapi32 CredDeleteW: deletes a credential.
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredDeleteW(string targetName, uint type, uint flags);
+ /// advapi32 CredFree: releases a buffer returned by CredReadW.
[DllImport("advapi32.dll", ExactSpelling = true)]
private static extern void CredFree(IntPtr buffer);
}
diff --git a/src/KofTwentyTwo.AppKit/Settings/AppTheme.cs b/src/KofTwentyTwo.AppKit/Settings/AppTheme.cs
new file mode 100644
index 0000000..6cbda9e
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit/Settings/AppTheme.cs
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+namespace KofTwentyTwo.AppKit.Settings;
+
+/// The app's color theme.
+public enum AppTheme
+{
+ /// Follow the Windows setting.
+ System,
+
+ /// Always light.
+ Light,
+
+ /// Always dark.
+ Dark,
+}
diff --git a/src/KofTwentyTwo.AppKit/Settings/ISanitizable.cs b/src/KofTwentyTwo.AppKit/Settings/ISanitizable.cs
new file mode 100644
index 0000000..7a83efa
--- /dev/null
+++ b/src/KofTwentyTwo.AppKit/Settings/ISanitizable.cs
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2026 James Maes (KofTwentyTwo)
+ * SPDX-License-Identifier: MIT
+ */
+
+namespace KofTwentyTwo.AppKit.Settings;
+
+/// Settings that repair out-of-range or missing values after loading.
+public interface ISanitizable
+{
+ /// Clamps, defaults, and validates every value in place.
+ void Sanitize();
+}
diff --git a/src/KofTwentyTwo.AppKit/Settings/SettingsStore.cs b/src/KofTwentyTwo.AppKit/Settings/SettingsStore.cs
index 200e1a0..0693e14 100644
--- a/src/KofTwentyTwo.AppKit/Settings/SettingsStore.cs
+++ b/src/KofTwentyTwo.AppKit/Settings/SettingsStore.cs
@@ -8,16 +8,6 @@ using System.Text.Json.Serialization.Metadata;
namespace KofTwentyTwo.AppKit.Settings;
-
-/// Settings that repair out-of-range or missing values after loading.
-public interface ISanitizable
-{
- /// Clamps, defaults, and validates every value in place.
- void Sanitize();
-}
-
-
-
///
/// Loads and saves a settings object as indented JSON. Takes a source-generated
/// so trimmed Release publishes need no reflection.
@@ -105,6 +95,7 @@ public sealed class SettingsStore
+ /// Deletes a file if it can; used to clean up the temporary file after a failed save.
private static void TryDelete(string path)
{
try
diff --git a/src/KofTwentyTwo.AppKit/Settings/ShellSettings.cs b/src/KofTwentyTwo.AppKit/Settings/ShellSettings.cs
index 9fcaea1..b154d16 100644
--- a/src/KofTwentyTwo.AppKit/Settings/ShellSettings.cs
+++ b/src/KofTwentyTwo.AppKit/Settings/ShellSettings.cs
@@ -7,22 +7,6 @@ using System.Text.Json.Serialization;
namespace KofTwentyTwo.AppKit.Settings;
-
-/// The app's color theme.
-public enum AppTheme
-{
- /// Follow the Windows setting.
- System,
-
- /// Always light.
- Light,
-
- /// Always dark.
- Dark,
-}
-
-
-
///
/// Preferences every AppKit app shares: theme, startup splash, and automatic update
/// checks. Apps derive their own settings class from this one and register the derived
diff --git a/tests/KofTwentyTwo.AppKit.Tests/CreditsTests.cs b/tests/KofTwentyTwo.AppKit.Tests/CreditsTests.cs
index 3403d20..ffd3fe3 100644
--- a/tests/KofTwentyTwo.AppKit.Tests/CreditsTests.cs
+++ b/tests/KofTwentyTwo.AppKit.Tests/CreditsTests.cs
@@ -5,8 +5,10 @@
namespace KofTwentyTwo.AppKit.Tests;
+/// Tests for the splash and About credit text.
public class CreditsTests
{
+ /// ContactLine: joins what is set.
[Fact]
public void ContactLine_JoinsWhatIsSet()
{
@@ -23,6 +25,7 @@ public class CreditsTests
+ /// ContactLine: empty when nothing is set.
[Fact]
public void ContactLine_EmptyWhenNothingIsSet()
{
@@ -32,6 +35,7 @@ public class CreditsTests
+ /// The owner profile is host/owner for any repository URL, or just the host when the path is empty.
[Theory]
[InlineData(null, null)]
[InlineData("https://github.com/", "github.com")]
diff --git a/tests/KofTwentyTwo.AppKit.Tests/Fakes.cs b/tests/KofTwentyTwo.AppKit.Tests/Fakes.cs
index 1abf978..307bcb4 100644
--- a/tests/KofTwentyTwo.AppKit.Tests/Fakes.cs
+++ b/tests/KofTwentyTwo.AppKit.Tests/Fakes.cs
@@ -13,6 +13,7 @@ namespace KofTwentyTwo.AppKit.Tests;
/// A per-test temporary directory, deleted on dispose.
internal sealed class TempDirectory : IDisposable
{
+ /// Creates a unique folder under the system temp directory.
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "appkit-tests", Guid.NewGuid().ToString("N"));
@@ -25,10 +26,12 @@ internal sealed class TempDirectory : IDisposable
+ /// A path inside the folder.
public string File(string name) => System.IO.Path.Combine(Path, name);
+ /// Deletes the folder; a handle a failing test left open is left for the OS temp cleaner.
public void Dispose()
{
try
@@ -51,6 +54,7 @@ internal sealed class ManualTimeProvider(DateTimeOffset now) : TimeProvider
+ /// The settable current time.
public override DateTimeOffset GetUtcNow() => Now;
@@ -71,14 +75,17 @@ internal sealed class RecordingLog : IActivityLog
+ /// Records an INFO entry.
public void Info(string message) => Entries.Add("INFO " + message);
+ /// Records a WARN entry.
public void Warning(string message) => Entries.Add("WARN " + message);
+ /// Records an ERROR entry (the exception is not needed by the tests).
public void Error(string message, Exception? exception = null) => Entries.Add("ERROR " + message);
}
@@ -93,6 +100,7 @@ internal sealed class RecordingPrompter(bool confirm = false) : IUserPrompter
+ /// Records the message.
public Task ShowMessageAsync(string title, string message)
{
Messages.Add((title, message));
@@ -101,6 +109,7 @@ internal sealed class RecordingPrompter(bool confirm = false) : IUserPrompter
+ /// Records the question and answers with the scripted choice.
public Task ConfirmAsync(string title, string message, string confirmText, string cancelText)
{
Confirmations.Add((title, message, confirmText, cancelText));
@@ -125,10 +134,12 @@ internal sealed class FakeUpdateService : IUpdateService
+ /// Returns the scripted check result.
public Task CheckAsync(CancellationToken cancellationToken = default) => Task.FromResult(NextCheck);
+ /// Counts the call and returns the scripted error, or null for success.
public Task DownloadAndApplyAsync(IProgress? progress = null, CancellationToken cancellationToken = default)
{
ApplyCalls++;
@@ -157,11 +168,13 @@ internal sealed class FakeBackend : IUpdateBackend
+ /// Returns the scripted available version, or fails with the scripted exception.
public Task CheckAsync()
=> CheckFailure is null ? Task.FromResult(Available) : Task.FromException(CheckFailure);
+ /// Counts the download and reports 100% progress, or fails with the scripted exception.
public Task DownloadAsync(Action? progress, CancellationToken cancellationToken)
{
if(DownloadFailure is not null)
@@ -175,5 +188,6 @@ internal sealed class FakeBackend : IUpdateBackend
+ /// Counts the apply; the real backend would exit the process here.
public void ApplyAndRestart() => Applies++;
}
diff --git a/tests/KofTwentyTwo.AppKit.Tests/IdentityTests.cs b/tests/KofTwentyTwo.AppKit.Tests/IdentityTests.cs
index 40b734d..8efb135 100644
--- a/tests/KofTwentyTwo.AppKit.Tests/IdentityTests.cs
+++ b/tests/KofTwentyTwo.AppKit.Tests/IdentityTests.cs
@@ -9,8 +9,10 @@ using System.Reflection.Emit;
namespace KofTwentyTwo.AppKit.Tests;
+/// Tests for app identity: id validation, defaults, and derived names.
public class AppInfoTests
{
+ /// Id: accepts valid ids.
[Theory]
[InlineData("gclo")]
[InlineData("my-app-2")]
@@ -23,6 +25,7 @@ public class AppInfoTests
+ /// Id: rejects invalid ids.
[Theory]
[InlineData("")]
[InlineData("Gclo")]
@@ -33,11 +36,12 @@ public class AppInfoTests
public void Id_RejectsInvalidIds(string? id)
{
ArgumentException ex = Assert.Throws(() => new AppInfo { Id = id!, DisplayName = "X" });
- Assert.Equal("Id", ex.ParamName);
+ Assert.Equal("value", ex.ParamName);
}
+ /// DataDirectoryVariable: is upper snake case of id.
[Fact]
public void DataDirectoryVariable_IsUpperSnakeCaseOfId()
{
@@ -46,6 +50,7 @@ public class AppInfoTests
+ /// Defaults: are empty and brand is set.
[Fact]
public void Defaults_AreEmptyAndBrandIsSet()
{
@@ -67,6 +72,7 @@ public class AppInfoTests
+ /// AllProperties: round trip.
[Fact]
public void AllProperties_RoundTrip()
{
@@ -94,8 +100,10 @@ public class AppInfoTests
+/// Tests for parsing brand colors.
public class ArgbColorTests
{
+ /// Parse: six digits is opaque.
[Fact]
public void Parse_SixDigits_IsOpaque()
{
@@ -104,6 +112,7 @@ public class ArgbColorTests
+ /// Parse: eight digits keeps alpha.
[Fact]
public void Parse_EightDigits_KeepsAlpha()
{
@@ -112,6 +121,7 @@ public class ArgbColorTests
+ /// Components: are exposed.
[Fact]
public void Components_AreExposed()
{
@@ -121,6 +131,7 @@ public class ArgbColorTests
+ /// TryParse: rejects malformed.
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -135,6 +146,7 @@ public class ArgbColorTests
+ /// Parse: malformed throws.
[Fact]
public void Parse_Malformed_Throws()
{
@@ -144,36 +156,40 @@ public class ArgbColorTests
+/// Tests for the per-user data paths and the data-directory override.
public class AppPathsTests
{
- private static readonly AppInfo App = new() { Id = "my-app", DisplayName = "My App" };
+ private static readonly AppInfo s_app = new() { Id = "my-app", DisplayName = "My App" };
+ /// DataRoot: defaults to local app data.
[Fact]
public void DataRoot_DefaultsToLocalAppData()
{
- var paths = new AppPaths(App, _ => null);
+ var paths = new AppPaths(s_app, _ => null);
string expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "my-app");
Assert.Equal(expected, paths.DataRoot);
}
+ /// DataRoot: empty override uses default.
[Fact]
public void DataRoot_EmptyOverride_UsesDefault()
{
- var paths = new AppPaths(App, _ => "");
+ var paths = new AppPaths(s_app, _ => "");
Assert.EndsWith("my-app", paths.DataRoot, StringComparison.Ordinal);
}
+ /// DataRoot: honors override variable.
[Fact]
public void DataRoot_HonorsOverrideVariable()
{
string? asked = null;
- var paths = new AppPaths(App, name =>
+ var paths = new AppPaths(s_app, name =>
{
asked = name;
return @"D:\portable";
@@ -187,6 +203,7 @@ public class AppPathsTests
+ /// PublicConstructor: reads the real environment.
[Fact]
public void PublicConstructor_ReadsTheRealEnvironment()
{
@@ -196,6 +213,7 @@ public class AppPathsTests
+ /// Constructor: null app throws.
[Fact]
public void Constructor_NullApp_Throws()
{
@@ -205,8 +223,10 @@ public class AppPathsTests
+/// Tests for the build identity text and prerelease detection.
public class BuildVersionTests
{
+ /// Format: shortens commit metadata.
[Theory]
[InlineData(null, "unknown")]
[InlineData(" ", "unknown")]
@@ -222,6 +242,7 @@ public class BuildVersionTests
+ /// IsPrerelease: looks only at the version part.
[Theory]
[InlineData(null, false)]
[InlineData("1.2.3", false)]
@@ -235,6 +256,7 @@ public class BuildVersionTests
+ /// Describe: reads informational version.
[Fact]
public void Describe_ReadsInformationalVersion()
{
@@ -245,6 +267,7 @@ public class BuildVersionTests
+ /// Describe: falls back to assembly version.
[Fact]
public void Describe_FallsBackToAssemblyVersion()
{
@@ -255,6 +278,7 @@ public class BuildVersionTests
+ /// Describe: no version attributes is zero version.
[Fact]
public void Describe_NoVersionAttributes_IsZeroVersion()
{
@@ -265,6 +289,7 @@ public class BuildVersionTests
+ /// NullAssembly: throws.
[Fact]
public void NullAssembly_Throws()
{
@@ -274,7 +299,8 @@ public class BuildVersionTests
- private static Assembly Emit(string? informationalVersion, Version? version)
+ /// Emits an in-memory assembly with the given informational and assembly versions, so every fallback branch can be tested.
+ private static AssemblyBuilder Emit(string? informationalVersion, Version? version)
{
var name = new AssemblyName("Emitted" + Guid.NewGuid().ToString("N")) { Version = version };
var builder = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run);
diff --git a/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs b/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs
index 673fd5c..c0ecc6a 100644
--- a/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs
+++ b/tests/KofTwentyTwo.AppKit.Tests/LoggingTests.cs
@@ -8,6 +8,7 @@ using KofTwentyTwo.AppKit.Logging;
namespace KofTwentyTwo.AppKit.Tests;
+/// Tests for the daily file log: entry format, rollover, retention, and failure handling.
public sealed class FileActivityLogTests : IDisposable
{
private readonly TempDirectory _temp = new();
@@ -15,10 +16,12 @@ public sealed class FileActivityLogTests : IDisposable
+ /// Deletes the test's temporary folder.
public void Dispose() => _temp.Dispose();
+ /// Writes: timestamped levels to todays file.
[Fact]
public void Writes_TimestampedLevels_ToTodaysFile()
{
@@ -39,6 +42,7 @@ public sealed class FileActivityLogTests : IDisposable
+ /// RollsOver: at midnight.
[Fact]
public void RollsOver_AtMidnight()
{
@@ -52,6 +56,7 @@ public sealed class FileActivityLogTests : IDisposable
+ /// Prunes: files outside retention once per day.
[Fact]
public void Prunes_FilesOutsideRetention_OncePerDay()
{
@@ -80,6 +85,7 @@ public sealed class FileActivityLogTests : IDisposable
+ /// Prune: skips files it cannot delete.
[Fact]
public void Prune_SkipsFilesItCannotDelete()
{
@@ -96,6 +102,7 @@ public sealed class FileActivityLogTests : IDisposable
+ /// ZeroRetention: keeps everything.
[Fact]
public void ZeroRetention_KeepsEverything()
{
@@ -108,6 +115,7 @@ public sealed class FileActivityLogTests : IDisposable
+ /// WriteFailures: are swallowed.
[Fact]
public void WriteFailures_AreSwallowed()
{
@@ -121,6 +129,7 @@ public sealed class FileActivityLogTests : IDisposable
+ /// ForApp: uses the apps logs folder and id.
[Fact]
public void ForApp_UsesTheAppsLogsFolderAndId()
{
@@ -136,6 +145,7 @@ public sealed class FileActivityLogTests : IDisposable
+ /// Constructor: validates arguments.
[Fact]
public void Constructor_ValidatesArguments()
{
@@ -146,6 +156,7 @@ public sealed class FileActivityLogTests : IDisposable
+ /// NullLog: discards everything.
[Fact]
public void NullLog_DiscardsEverything()
{
@@ -160,16 +171,19 @@ public sealed class FileActivityLogTests : IDisposable
+/// Tests for reading and filtering log tails.
public sealed class LogTailTests : IDisposable
{
private readonly TempDirectory _temp = new();
+ /// Deletes the test's temporary folder.
public void Dispose() => _temp.Dispose();
+ /// Read: missing file is empty.
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -183,6 +197,7 @@ public sealed class LogTailTests : IDisposable
+ /// Read: keeps only the tail.
[Fact]
public void Read_KeepsOnlyTheTail()
{
@@ -195,6 +210,7 @@ public sealed class LogTailTests : IDisposable
+ /// Read: locked file returns an explanation.
[Fact]
public void Read_LockedFile_ReturnsAnExplanation()
{
@@ -208,6 +224,7 @@ public sealed class LogTailTests : IDisposable
+ /// Read: rejects non positive line counts.
[Fact]
public void Read_RejectsNonPositiveLineCounts()
{
@@ -216,6 +233,7 @@ public sealed class LogTailTests : IDisposable
+ /// ErrorsOnly: keeps error entries with continuation lines.
[Fact]
public void ErrorsOnly_KeepsErrorEntriesWithContinuationLines()
{
@@ -235,6 +253,7 @@ public sealed class LogTailTests : IDisposable
+ /// ToDisplayText: has friendly empty states.
[Fact]
public void ToDisplayText_HasFriendlyEmptyStates()
{
@@ -245,6 +264,7 @@ public sealed class LogTailTests : IDisposable
+ /// NullArguments: throw.
[Fact]
public void NullArguments_Throw()
{
diff --git a/tests/KofTwentyTwo.AppKit.Tests/SecretsTests.cs b/tests/KofTwentyTwo.AppKit.Tests/SecretsTests.cs
index 500ae94..8d61d13 100644
--- a/tests/KofTwentyTwo.AppKit.Tests/SecretsTests.cs
+++ b/tests/KofTwentyTwo.AppKit.Tests/SecretsTests.cs
@@ -9,8 +9,10 @@ using KofTwentyTwo.AppKit.Secrets;
namespace KofTwentyTwo.AppKit.Tests;
+/// Tests for the in-memory secret vault.
public class InMemorySecretVaultTests
{
+ /// Store, overwrite, retrieve, and delete behave like a dictionary, and deleting twice is harmless.
[Fact]
public void StoreRetrieveDelete()
{
@@ -26,6 +28,7 @@ public class InMemorySecretVaultTests
+ /// Empty keys and null secrets are rejected.
[Fact]
public void ValidatesArguments()
{
@@ -46,6 +49,7 @@ public sealed class CredentialManagerVaultTests : IDisposable
+ /// Removes the credentials the test may have created.
public void Dispose()
{
_vault.Delete("token");
@@ -54,6 +58,7 @@ public sealed class CredentialManagerVaultTests : IDisposable
+ /// A secret round-trips through the Credential Manager, overwrites in place, and deletes cleanly.
[Fact]
public void StoreRetrieveOverwriteDelete()
{
@@ -68,6 +73,7 @@ public sealed class CredentialManagerVaultTests : IDisposable
+ /// EmptySecret: round trips.
[Fact]
public void EmptySecret_RoundTrips()
{
@@ -77,6 +83,7 @@ public sealed class CredentialManagerVaultTests : IDisposable
+ /// TargetName: is prefix colon key.
[Fact]
public void TargetName_IsPrefixColonKey()
{
@@ -87,6 +94,7 @@ public sealed class CredentialManagerVaultTests : IDisposable
+ /// OversizedSecret: fails loudly.
[Fact]
public void OversizedSecret_FailsLoudly()
{
@@ -98,6 +106,7 @@ public sealed class CredentialManagerVaultTests : IDisposable
+ /// InvalidTargets: surface native errors.
[Fact]
public void InvalidTargets_SurfaceNativeErrors()
{
@@ -107,6 +116,7 @@ public sealed class CredentialManagerVaultTests : IDisposable
+ /// Constructor: validates platform and prefix.
[Fact]
public void Constructor_ValidatesPlatformAndPrefix()
{
diff --git a/tests/KofTwentyTwo.AppKit.Tests/SettingsTests.cs b/tests/KofTwentyTwo.AppKit.Tests/SettingsTests.cs
index 1341d9d..20c61f1 100644
--- a/tests/KofTwentyTwo.AppKit.Tests/SettingsTests.cs
+++ b/tests/KofTwentyTwo.AppKit.Tests/SettingsTests.cs
@@ -16,6 +16,7 @@ public sealed class SampleSettings : ShellSettings
+ /// Repairs the shell settings, then clamps the app's own value.
public override void Sanitize()
{
base.Sanitize();
@@ -33,6 +34,7 @@ public sealed class PlainSettings
+/// Source-generated serializer for the test settings types.
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(SampleSettings))]
[JsonSerializable(typeof(PlainSettings))]
@@ -42,21 +44,25 @@ internal sealed partial class TestSettingsContext : JsonSerializerContext
+/// Tests for loading and saving settings, including corrupt files and failed writes.
public sealed class SettingsStoreTests : IDisposable
{
private readonly TempDirectory _temp = new();
+ /// Deletes the test's temporary folder.
public void Dispose() => _temp.Dispose();
+ /// A store for the sample settings, by default in a nested folder that does not exist yet.
private SettingsStore Store(string? path = null)
=> new(path ?? _temp.File(Path.Combine("nested", "settings.json")), TestSettingsContext.Default.SampleSettings);
+ /// Load: missing file returns defaults.
[Fact]
public void Load_MissingFile_ReturnsDefaults()
{
@@ -67,6 +73,7 @@ public sealed class SettingsStoreTests : IDisposable
+ /// SaveThenLoad: round trips.
[Fact]
public void SaveThenLoad_RoundTrips()
{
@@ -86,6 +93,7 @@ public sealed class SettingsStoreTests : IDisposable
+ /// Load: corrupt or null file returns defaults.
[Theory]
[InlineData("{ not json")]
[InlineData("null")]
@@ -99,6 +107,7 @@ public sealed class SettingsStoreTests : IDisposable
+ /// Load: sanitizes out of range values.
[Fact]
public void Load_SanitizesOutOfRangeValues()
{
@@ -113,6 +122,7 @@ public sealed class SettingsStoreTests : IDisposable
+ /// Save: failure returns false.
[Fact]
public void Save_FailureReturnsFalse()
{
@@ -123,6 +133,7 @@ public sealed class SettingsStoreTests : IDisposable
+ /// Save: swap fails cleans up the temporary file.
[Fact]
public void Save_SwapFails_CleansUpTheTemporaryFile()
{
@@ -134,6 +145,7 @@ public sealed class SettingsStoreTests : IDisposable
+ /// Save: temporary path blocked returns false and leaves original.
[Fact]
public void Save_TemporaryPathBlocked_ReturnsFalseAndLeavesOriginal()
{
@@ -156,6 +168,7 @@ public sealed class SettingsStoreTests : IDisposable
+ /// Save: bare file name writes relative to current directory.
[Fact]
public void Save_BareFileName_WritesRelativeToCurrentDirectory()
{
@@ -173,6 +186,7 @@ public sealed class SettingsStoreTests : IDisposable
+ /// Constructor: and save validate arguments.
[Fact]
public void Constructor_And_Save_ValidateArguments()
{
@@ -184,8 +198,10 @@ public sealed class SettingsStoreTests : IDisposable
+/// Tests for the shared shell settings and their repair rules.
public class ShellSettingsTests
{
+ /// ThemeKind: tolerates bad stored values.
[Theory]
[InlineData("Light", AppTheme.Light)]
[InlineData("Dark", AppTheme.Dark)]
@@ -200,6 +216,7 @@ public class ShellSettingsTests
+ /// Sanitize: clamps splash.
[Theory]
[InlineData(0, ShellSettings.DefaultSplashMilliseconds)]
[InlineData(1, ShellSettings.MinSplashMilliseconds)]
@@ -215,6 +232,7 @@ public class ShellSettingsTests
+ /// Fresh settings show the splash, check for updates, and use the default splash time.
[Fact]
public void Defaults()
{
diff --git a/tests/KofTwentyTwo.AppKit.Tests/UpdatesTests.cs b/tests/KofTwentyTwo.AppKit.Tests/UpdatesTests.cs
index 2fd5cba..ce5ea7d 100644
--- a/tests/KofTwentyTwo.AppKit.Tests/UpdatesTests.cs
+++ b/tests/KofTwentyTwo.AppKit.Tests/UpdatesTests.cs
@@ -8,8 +8,10 @@ using KofTwentyTwo.AppKit.Updates;
namespace KofTwentyTwo.AppKit.Tests;
+/// Tests for update check results.
public class UpdateCheckResultTests
{
+ /// Only a result with a version and no error counts as an available update.
[Fact]
public void Factories()
{
@@ -22,8 +24,10 @@ public class UpdateCheckResultTests
+/// Tests for the update service's guarding and state, over a fake Velopack backend.
public class VelopackUpdateServiceTests
{
+ /// NotInstalled: is unsupported.
[Fact]
public async Task NotInstalled_IsUnsupported()
{
@@ -36,6 +40,7 @@ public class VelopackUpdateServiceTests
+ /// BackendThatCannotBeCreated: is unsupported and never throws.
[Fact]
public async Task BackendThatCannotBeCreated_IsUnsupportedAndNeverThrows()
{
@@ -48,6 +53,7 @@ public class VelopackUpdateServiceTests
+ /// Check: finds update then downloads applies with progress.
[Fact]
public async Task Check_FindsUpdate_ThenDownloadsAppliesWithProgress()
{
@@ -69,6 +75,7 @@ public class VelopackUpdateServiceTests
+ /// Check: up to date leaves nothing to install.
[Fact]
public async Task Check_UpToDate_LeavesNothingToInstall()
{
@@ -79,6 +86,7 @@ public class VelopackUpdateServiceTests
+ /// Check: failure is reported and clears pending update.
[Fact]
public async Task Check_Failure_IsReportedAndClearsPendingUpdate()
{
@@ -93,6 +101,7 @@ public class VelopackUpdateServiceTests
+ /// DownloadFailure: is returned.
[Fact]
public async Task DownloadFailure_IsReturned()
{
@@ -105,6 +114,7 @@ public class VelopackUpdateServiceTests
+ /// RealVelopack: outside an install is unsupported.
[Fact]
public async Task RealVelopack_OutsideAnInstall_IsUnsupported()
{
@@ -117,6 +127,7 @@ public class VelopackUpdateServiceTests
+ /// NullRepository: throws.
[Fact]
public void NullRepository_Throws()
{
@@ -125,14 +136,17 @@ public class VelopackUpdateServiceTests
+ /// An IProgress that reports immediately on the calling thread, so assertions see the values.
private sealed class SynchronousProgress(Action report) : IProgress
{
+ /// Forwards the value to the callback.
public void Report(int value) => report(value);
}
}
+/// Tests for the interactive and quiet update flows.
public class UpdateCoordinatorTests
{
private readonly FakeUpdateService _updates = new();
@@ -140,10 +154,12 @@ public class UpdateCoordinatorTests
+ /// A coordinator over the fake service and the given prompter, logging to the recording log.
private UpdateCoordinator Coordinator(RecordingPrompter prompter) => new(_updates, prompter, "Sample", _log);
+ /// Interactive: unsupported says so.
[Fact]
public async Task Interactive_Unsupported_SaysSo()
{
@@ -155,6 +171,7 @@ public class UpdateCoordinatorTests
+ /// Interactive: failure explains and logs.
[Fact]
public async Task Interactive_Failure_ExplainsAndLogs()
{
@@ -167,6 +184,7 @@ public class UpdateCoordinatorTests
+ /// Interactive: up to date shows current version.
[Theory]
[InlineData("1.0.0", "You are up to date (v1.0.0).")]
[InlineData(null, "You are up to date.")]
@@ -180,6 +198,7 @@ public class UpdateCoordinatorTests
+ /// Interactive: available declined does not install.
[Fact]
public async Task Interactive_Available_Declined_DoesNotInstall()
{
@@ -196,6 +215,7 @@ public class UpdateCoordinatorTests
+ /// Interactive: available accepted installs.
[Fact]
public async Task Interactive_Available_Accepted_Installs()
{
@@ -209,6 +229,7 @@ public class UpdateCoordinatorTests
+ /// Install: failure is shown and logged.
[Fact]
public async Task Install_Failure_IsShownAndLogged()
{
@@ -222,6 +243,7 @@ public class UpdateCoordinatorTests
+ /// Quiet: unsupported up to date and failure stay silent.
[Fact]
public async Task Quiet_Unsupported_UpToDate_AndFailure_StaySilent()
{
@@ -242,6 +264,7 @@ public class UpdateCoordinatorTests
+ /// Quiet: available offers the update.
[Fact]
public async Task Quiet_Available_OffersTheUpdate()
{
@@ -255,6 +278,7 @@ public class UpdateCoordinatorTests
+ /// NullLog: is allowed.
[Fact]
public async Task NullLog_IsAllowed()
{
@@ -264,6 +288,7 @@ public class UpdateCoordinatorTests
+ /// Constructor: validates arguments.
[Fact]
public void Constructor_ValidatesArguments()
{
diff --git a/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs b/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs
index 240f209..5b41f4b 100644
--- a/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs
+++ b/tests/KofTwentyTwo.AppKit.UiTests/AppSession.cs
@@ -33,12 +33,13 @@ public enum SampleKind
///
public sealed class AppSession : IDisposable
{
- private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
- private static readonly TimeSpan LaunchTimeout = TimeSpan.FromSeconds(30);
- private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250);
+ private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan s_launchTimeout = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan s_pollInterval = TimeSpan.FromMilliseconds(250);
+ /// Launches the sample against a fresh temporary data folder and waits for its main window.
public AppSession(SampleKind kind, bool showSplash = false)
{
ExePath = ResolveExePath(kind);
@@ -110,20 +111,22 @@ public sealed class AppSession : IDisposable
+ /// Retries the lookup until it returns an element, or fails naming what was awaited.
public AutomationElement WaitFor(Func lookup, string description, TimeSpan? timeout = null)
{
- TimeSpan effective = timeout ?? DefaultTimeout;
- RetryResult result = Retry.WhileNull(lookup, timeout: effective, interval: PollInterval, ignoreException: true);
+ TimeSpan effective = timeout ?? s_defaultTimeout;
+ RetryResult result = Retry.WhileNull(lookup, timeout: effective, interval: s_pollInterval, ignoreException: true);
return result.Result
?? throw new TimeoutException($"Timed out after {effective.TotalSeconds:0}s waiting for {description}. (app exited: {App.HasExited})");
}
+ /// Retries until the lookup returns nothing, or fails naming what was expected to disappear.
public static void WaitUntilGone(Func lookup, string description, TimeSpan? timeout = null)
{
- TimeSpan effective = timeout ?? DefaultTimeout;
- if(!Retry.WhileTrue(() => lookup() is not null, timeout: effective, interval: PollInterval, ignoreException: true).Success)
+ TimeSpan effective = timeout ?? s_defaultTimeout;
+ if(!Retry.WhileTrue(() => lookup() is not null, timeout: effective, interval: s_pollInterval, ignoreException: true).Success)
{
throw new TimeoutException($"Timed out after {effective.TotalSeconds:0}s waiting for {description} to go away.");
}
@@ -131,11 +134,13 @@ public sealed class AppSession : IDisposable
+ /// Waits for the element with the given automation id in any of the app's windows.
public AutomationElement WaitForElement(string automationId, TimeSpan? timeout = null)
=> WaitFor(() => FindInApp(cf => cf.ByAutomationId(automationId)), $"element '{automationId}'", timeout);
+ /// Waits until no element with the given automation id remains.
public void WaitForElementGone(string automationId, TimeSpan? timeout = null)
=> WaitUntilGone(() => FindInApp(cf => cf.ByAutomationId(automationId)), $"element '{automationId}'", timeout);
@@ -164,7 +169,7 @@ public sealed class AppSession : IDisposable
Invoke(menu);
}
Wait.UntilInputIsProcessed();
- item = Retry.WhileNull(() => FindInApp(cf => cf.ByAutomationId(itemAutomationId)), timeout: TimeSpan.FromSeconds(3), interval: PollInterval, ignoreException: true).Result;
+ item = Retry.WhileNull(() => FindInApp(cf => cf.ByAutomationId(itemAutomationId)), timeout: TimeSpan.FromSeconds(3), interval: s_pollInterval, ignoreException: true).Result;
}
Invoke(item ?? throw new TimeoutException($"Menu item '{itemAutomationId}' never appeared under '{menuName}'. (app exited: {App.HasExited})"));
}
@@ -194,6 +199,7 @@ public sealed class AppSession : IDisposable
+ /// Invokes an element through UI Automation, falling back to a mouse click.
private static void Invoke(AutomationElement element)
{
if(element.Patterns.Invoke.TryGetPattern(out IInvokePattern? invoke))
@@ -209,6 +215,7 @@ public sealed class AppSession : IDisposable
+ /// Waits for the app's main window, failing fast if the process exits.
private Window WaitForMainWindow()
{
RetryResult result = Retry.WhileNull(
@@ -227,13 +234,14 @@ public sealed class AppSession : IDisposable
return null;
}
},
- timeout: LaunchTimeout,
- interval: PollInterval);
- return result.Result ?? throw new TimeoutException($"The main window did not appear within {LaunchTimeout.TotalSeconds:0}s. Exe: {ExePath}");
+ timeout: s_launchTimeout,
+ interval: s_pollInterval);
+ return result.Result ?? throw new TimeoutException($"The main window did not appear within {s_launchTimeout.TotalSeconds:0}s. Exe: {ExePath}");
}
+ /// Reads a file the app may still be writing, without locking it.
private static string ReadShared(string path)
{
if(!File.Exists(path))
@@ -280,6 +288,7 @@ public sealed class AppSession : IDisposable
+ /// Closes the app (killing it if needed), then disposes the automation and deletes the data folder.
public void Dispose()
{
try
diff --git a/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs b/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs
index 081545f..c3d5d13 100644
--- a/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs
+++ b/tests/KofTwentyTwo.AppKit.UiTests/SampleAppTests.cs
@@ -15,6 +15,7 @@ namespace KofTwentyTwo.AppKit.UiTests;
[Collection("Desktop")]
public class SampleAppTests
{
+ /// Splash: shows then dismisses itself.
[Theory]
[InlineData(SampleKind.WinUI)]
[InlineData(SampleKind.Wpf)]
@@ -27,6 +28,7 @@ public class SampleAppTests
+ /// About: shows the build version.
[Theory]
[InlineData(SampleKind.WinUI)]
[InlineData(SampleKind.Wpf)]
@@ -42,6 +44,7 @@ public class SampleAppTests
+ /// UnhandledException: is logged and the app survives.
[Theory]
[InlineData(SampleKind.WinUI)]
[InlineData(SampleKind.Wpf)]
@@ -58,6 +61,7 @@ public class SampleAppTests
+ /// ActivityLog: opens a window showing the log.
[Theory]
[InlineData(SampleKind.WinUI)]
[InlineData(SampleKind.Wpf)]
@@ -72,6 +76,7 @@ public class SampleAppTests
+ /// ThemeChoice: is saved.
[Theory]
[InlineData(SampleKind.WinUI)]
[InlineData(SampleKind.Wpf)]
@@ -86,6 +91,7 @@ public class SampleAppTests
+ /// CheckForUpdates: in a loose build says updates need an install.
[Theory]
[InlineData(SampleKind.WinUI)]
[InlineData(SampleKind.Wpf)]
@@ -102,6 +108,7 @@ public class SampleAppTests
+/// UI tests share one desktop, so the collection never runs in parallel.
[CollectionDefinition("Desktop", DisableParallelization = true)]
public class DesktopCollectionDefinition
{
diff --git a/tools/BrandTool/Program.cs b/tools/BrandTool/Program.cs
index 94e60b4..2d04917 100644
--- a/tools/BrandTool/Program.cs
+++ b/tools/BrandTool/Program.cs
@@ -22,10 +22,11 @@ internal static class Program
private const double Box = 512;
private const double TileInset = 48;
private const double TileRadius = 104;
- private static readonly int[] IconSizes = [16, 20, 24, 32, 40, 48, 64, 128, 256];
+ private static readonly int[] s_iconSizes = [16, 20, 24, 32, 40, 48, 64, 128, 256];
+ /// Entry point: reads the options, then writes app-icon.svg and a multi-size app.ico to the output folder.
[STAThread]
private static int Main(string[] args)
{
@@ -44,13 +45,14 @@ internal static class Program
Directory.CreateDirectory(outDir);
File.WriteAllText(Path.Combine(outDir, "app-icon.svg"), Svg(spec));
- WriteIco(Path.Combine(outDir, "app.ico"), IconSizes.Select(size => Png(spec, size)).ToList(), IconSizes);
+ WriteIco(Path.Combine(outDir, "app.ico"), s_iconSizes.Select(size => Png(spec, size)).ToList(), s_iconSizes);
Console.WriteLine($"Wrote app-icon.svg and app.ico to {Path.GetFullPath(outDir)}");
return 0;
}
+ /// Parses --name value pairs into a case-insensitive dictionary.
private static Dictionary Parse(string[] args)
{
var options = new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -63,6 +65,7 @@ internal static class Program
+ /// The mark as SVG text: the rounded gradient tile and the white stroked glyph.
private static string Svg(MarkSpec spec)
=> $"""