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) => $""" @@ -81,6 +84,7 @@ internal static class Program + /// Renders the mark with WPF as a PNG of the given pixel size. private static byte[] Png(MarkSpec spec, int size) { var visual = new DrawingVisual(); @@ -106,7 +110,7 @@ internal static class Program /// Writes an .ico whose frames are embedded PNGs (supported since Windows Vista). - private static void WriteIco(string path, IReadOnlyList pngs, IReadOnlyList sizes) + private static void WriteIco(string path, List pngs, int[] sizes) { using var writer = new BinaryWriter(File.Create(path)); writer.Write((ushort)0); // reserved @@ -133,6 +137,7 @@ internal static class Program + /// Parses a #RRGGBB or #AARRGGBB string into a WPF color. private static Color ToColor(string hex) { var color = ArgbColor.Parse(hex); @@ -141,5 +146,6 @@ internal static class Program + /// The mark definition: gradient colors, the glyph's SVG path data, and its stroke width. private sealed record MarkSpec(string Start, string End, string Glyph, double Stroke); }