From a53fd8e3a49624514d1c5802f342e66a7b70fefa Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 12 Oct 2026 23:18:02 -0700 Subject: [PATCH 1/4] Show one policy line on the Prefs tab and add a read-only Services card Settings that policy sets lose their per-field "Managed by policy" caption and lock; their controls stay disabled in the default gray, and one line at the top of the tab says what gray means, shown only when policy sets at least one value. A Services card at the bottom of the tab lists the StartSet service the installer creates, with its state, last exit code and start type in plain words, read through WMI so it needs no elevation. --- src/App/ViewModels/PrefsViewModel.cs | 27 ++++- src/App/Views/PrefsPage.xaml | 75 ++++++++++++ src/App/Views/PrefsPage.xaml.cs | 34 ++---- src/Infrastructure/Gui/InstalledServices.cs | 109 ++++++++++++++++++ .../Infrastructure/InstalledServicesTests.cs | 61 ++++++++++ 5 files changed, 282 insertions(+), 24 deletions(-) create mode 100644 src/Infrastructure/Gui/InstalledServices.cs create mode 100644 tests/StartSet.Tests/Infrastructure/InstalledServicesTests.cs diff --git a/src/App/ViewModels/PrefsViewModel.cs b/src/App/ViewModels/PrefsViewModel.cs index be35a17..5a8f30e 100644 --- a/src/App/ViewModels/PrefsViewModel.cs +++ b/src/App/ViewModels/PrefsViewModel.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using StartSet.Infrastructure.Configuration; using StartSet.Infrastructure.Gui; @@ -23,10 +24,14 @@ public sealed class SettingItem public string Name => Setting.Name; public bool IsManaged => Source == SettingSource.Policy; - /// Where the shown value comes from, as the caption under the control says it. + /// + /// Where the shown value comes from, as the caption under the control says it. A value set + /// by policy has no caption: its control is grayed out, and one line at the top of the tab + /// says what gray means. + /// public string SourceText => Source switch { - SettingSource.Policy => "Managed by policy", + SettingSource.Policy => "", SettingSource.MachineSettings => "Machine setting", SettingSource.LegacyFile => "From Config.yaml", SettingSource.CommandLine => "Command line", @@ -78,6 +83,23 @@ public partial class PrefsViewModel : ObservableObject public int ManagedCount => Items.Count(i => i.IsManaged); + /// True when policy sets at least one value, which shows the policy line at the top. + public bool HasPolicySettings => ManagedCount > 0; + + // ── Services ───────────────────────────────────────────────── + + /// The installer's services and scheduled tasks, for the read-only Services card. + public ObservableCollection Services { get; } = []; + + /// Queries the services off the UI thread and replaces the card's rows. + public async Task RefreshServicesAsync() + { + var rows = await Task.Run(InstalledServices.Query); + Services.Clear(); + foreach (var row in rows) + Services.Add(row); + } + public string VersionDisplay { get @@ -112,6 +134,7 @@ public void Load() OnPropertyChanged(nameof(Items)); OnPropertyChanged(nameof(ManagedCount)); + OnPropertyChanged(nameof(HasPolicySettings)); } private static SettingKind KindOf(PreferenceSetting setting) diff --git a/src/App/Views/PrefsPage.xaml b/src/App/Views/PrefsPage.xaml index ee957a4..49b9bd4 100644 --- a/src/App/Views/PrefsPage.xaml +++ b/src/App/Views/PrefsPage.xaml @@ -2,6 +2,7 @@ x:Class="StartSet.App.Views.PrefsPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:gui="using:StartSet.Infrastructure.Gui" NavigationCacheMode="Required"> @@ -34,6 +35,16 @@ HorizontalAlignment="Center" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App/Views/PrefsPage.xaml.cs b/src/App/Views/PrefsPage.xaml.cs index 38a9d76..26aa9cf 100644 --- a/src/App/Views/PrefsPage.xaml.cs +++ b/src/App/Views/PrefsPage.xaml.cs @@ -37,8 +37,11 @@ protected override void OnNavigatedTo(NavigationEventArgs e) base.OnNavigatedTo(e); ViewModel.Load(); BuildCards(); + _ = ViewModel.RefreshServicesAsync(); } + private void RefreshServices_Click(object sender, RoutedEventArgs e) => _ = ViewModel.RefreshServicesAsync(); + private void UnlockButton_Click(object sender, RoutedEventArgs e) { // Once the elevated copy has started (UAC accepted), this read-only instance closes. @@ -126,35 +129,22 @@ private Grid Row(SettingItem item) control.IsEnabled = item.CanEdit; control.VerticalAlignment = VerticalAlignment.Center; ToolTipService.SetToolTip(control, item.IsManaged - ? $"Set by policy (HKLM\\SOFTWARE\\Policies\\StartSet\\{item.Name})" + ? $"HKLM\\SOFTWARE\\Policies\\StartSet\\{item.Name}" : item.Name); Grid.SetColumn(control, 1); grid.Children.Add(control); return grid; } - /// The value's source, with a lock when policy sets it. - private static StackPanel SourceCaption(SettingItem item) + /// The value's source. Empty, and collapsed, when policy sets it. + private static TextBlock SourceCaption(SettingItem item) => new() { - var secondary = (Brush)Application.Current.Resources["TextFillColorTertiaryBrush"]; - var caption = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, Margin = new Thickness(0, 2, 0, 0) }; - if (item.IsManaged) - { - caption.Children.Add(new FontIcon - { - Glyph = "", - FontSize = 12, - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - } - caption.Children.Add(new TextBlock - { - Text = item.SourceText, - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = item.IsManaged ? (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] : secondary - }); - return caption; - } + Text = item.SourceText, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorTertiaryBrush"], + Margin = new Thickness(0, 2, 0, 0), + Visibility = string.IsNullOrEmpty(item.SourceText) ? Visibility.Collapsed : Visibility.Visible + }; private Control Editor(SettingItem item) { diff --git a/src/Infrastructure/Gui/InstalledServices.cs b/src/Infrastructure/Gui/InstalledServices.cs new file mode 100644 index 0000000..cedcc2b --- /dev/null +++ b/src/Infrastructure/Gui/InstalledServices.cs @@ -0,0 +1,109 @@ +using System.Management; + +namespace StartSet.Infrastructure.Gui; + +/// A Windows service or scheduled task the StartSet installer creates. +public sealed record FootprintEntry(string Name, string FriendlyName, string Kind, string Description); + +/// One row of the Prefs tab's Services card. +public sealed record FootprintStatus( + string FriendlyName, + string Name, + string Kind, + string Description, + string State, + string LastResult, + string Trigger) +{ + public string NameAndKind => $"{Name} · {Kind}"; +} + +/// +/// The services and scheduled tasks StartSet installs, and their state for the Prefs tab's +/// read-only Services card. +/// +/// +/// The list is the installer's footprint, kept by hand: the package's postinstall.ps1 and the +/// MSI both create the one service, StartSet, set to start automatically (not delayed). Neither +/// creates a scheduled task. State comes from WMI's Win32_Service, which a standard user may +/// read, so the card needs no elevation; a query that fails shows "Unknown". +/// +public static class InstalledServices +{ + public const string ServiceKind = "Service"; + + public static IReadOnlyList Footprint { get; } = + [ + new("StartSet", "StartSet Service", ServiceKind, + "Runs boot payloads at start-up, login payloads at each sign-in, and on-demand payloads when asked."), + ]; + + /// Queries every footprint entry. Blocking; call it off the UI thread. + public static IReadOnlyList Query() => Footprint.Select(QueryService).ToList(); + + private static FootprintStatus QueryService(FootprintEntry entry) + { + try + { + using var searcher = new ManagementObjectSearcher( + $"SELECT State, StartMode, DelayedAutoStart, ExitCode FROM Win32_Service WHERE Name = '{entry.Name}'"); + using var results = searcher.Get(); + var service = results.Cast().FirstOrDefault(); + if (service is null) + return Row(entry, NotInstalled, "—", "—"); + + var state = service["State"] as string; + var startMode = service["StartMode"] as string; + var delayed = service["DelayedAutoStart"] is true; + var exitCode = service["ExitCode"] is uint code ? (int?)code : null; + return Row(entry, ServiceStateText(state, startMode), ServiceLastResultText(state, exitCode), StartModeText(startMode, delayed)); + } + catch (Exception) + { + return Row(entry, Unknown, Unknown, Unknown); + } + } + + private static FootprintStatus Row(FootprintEntry entry, string state, string lastResult, string trigger) => + new(entry.FriendlyName, entry.Name, entry.Kind, entry.Description, state, lastResult, trigger); + + // ── Plain words ───────────────────────────────────────────── + + public const string NotInstalled = "Not installed"; + public const string Unknown = "Unknown"; + + /// Win32_Service.StartMode ("Auto", "Manual", "Disabled", "Boot", "System") in words. + public static string StartModeText(string? startMode, bool delayed) => startMode switch + { + "Auto" when delayed => "Starts automatically (delayed) at boot", + "Auto" or "Boot" or "System" => "Starts automatically at boot", + "Manual" => "Starts manually", + "Disabled" => "Disabled", + _ => Unknown + }; + + /// + /// Win32_Service.State in words. A disabled service reads "Disabled" whatever it is doing, + /// since that is what stops it running. + /// + public static string ServiceStateText(string? state, string? startMode) + { + if (startMode == "Disabled") return "Disabled"; + return state switch + { + "Running" => "Running", + "Stopped" => "Stopped", + "Start Pending" or "Continue Pending" => "Starting", + "Stop Pending" or "Pause Pending" => "Stopping", + "Paused" => "Paused", + _ => Unknown + }; + } + + /// + /// The service's Win32 exit code, which only means something once it has stopped. While it + /// runs there is no last result to report. + /// + public static string ServiceLastResultText(string? state, int? exitCode) => + state == "Stopped" && exitCode is { } code ? $"Exit code {code}" : "—"; +} diff --git a/tests/StartSet.Tests/Infrastructure/InstalledServicesTests.cs b/tests/StartSet.Tests/Infrastructure/InstalledServicesTests.cs new file mode 100644 index 0000000..8b207d6 --- /dev/null +++ b/tests/StartSet.Tests/Infrastructure/InstalledServicesTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using StartSet.Infrastructure.Gui; + +namespace StartSet.Tests.Infrastructure; + +/// The plain words on the Prefs tab's Services card. +public class InstalledServicesTests +{ + [Fact] + public void Footprint_IsTheOneServiceTheInstallerCreates() + { + InstalledServices.Footprint.Should().ContainSingle() + .Which.Should().Match(e => e.Name == "StartSet" && e.Kind == InstalledServices.ServiceKind); + } + + [Theory] + [InlineData("Auto", false, "Starts automatically at boot")] + [InlineData("Auto", true, "Starts automatically (delayed) at boot")] + [InlineData("Boot", false, "Starts automatically at boot")] + [InlineData("Manual", false, "Starts manually")] + [InlineData("Manual", true, "Starts manually")] + [InlineData("Disabled", false, "Disabled")] + [InlineData(null, false, "Unknown")] + [InlineData("Something", false, "Unknown")] + public void StartMode_InWords(string? startMode, bool delayed, string expected) + { + InstalledServices.StartModeText(startMode, delayed).Should().Be(expected); + } + + [Theory] + [InlineData("Running", "Auto", "Running")] + [InlineData("Stopped", "Manual", "Stopped")] + [InlineData("Stopped", "Disabled", "Disabled")] + [InlineData("Running", "Disabled", "Disabled")] + [InlineData("Start Pending", "Auto", "Starting")] + [InlineData("Stop Pending", "Auto", "Stopping")] + [InlineData(null, "Auto", "Unknown")] + public void State_InWords(string? state, string? startMode, string expected) + { + InstalledServices.ServiceStateText(state, startMode).Should().Be(expected); + } + + [Theory] + [InlineData("Stopped", 0, "Exit code 0")] + [InlineData("Stopped", 1067, "Exit code 1067")] + [InlineData("Running", 0, "—")] + [InlineData("Stopped", null, "—")] + public void LastResult_IsTheExitCode_OnlyOnceStopped(string? state, int? exitCode, string expected) + { + InstalledServices.ServiceLastResultText(state, exitCode).Should().Be(expected); + } + + [Fact] + public void Query_ReturnsARow_ForEveryEntry_EvenWhenNotInstalled() + { + var rows = InstalledServices.Query(); + + rows.Select(r => r.Name).Should().Equal(InstalledServices.Footprint.Select(e => e.Name)); + rows.Should().OnlyContain(r => !string.IsNullOrEmpty(r.State) && !string.IsNullOrEmpty(r.Trigger)); + } +} From 02af41c51c48483302f3985892bb8133bb8ef7b8 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 12 Oct 2026 23:40:52 -0700 Subject: [PATCH 2/4] Edit settings in the normal app and ask for administrator approval at Save The Prefs tab no longer has a read-only mode with an Unlock button that relaunched the whole app elevated. Every setting policy does not set can be edited straight away, and edits are held until Save or Discard. Save writes machine settings directly when the app is already elevated; otherwise it writes the edits to a JSON file in the user's temp folder and starts the app's own executable elevated with --apply-settings. That run starts no window: it reads policy again, skips every setting policy sets, checks each value with the Prefs tab's rules, writes the rest and exits. The temp file is deleted afterwards. A declined UAC prompt keeps the edits pending. --- README.md | 4 +- src/App/MainWindow.xaml.cs | 5 +- src/App/Program.cs | 28 +++ src/App/StartSet.App.csproj | 2 + src/App/ViewModels/PrefsViewModel.cs | 194 ++++++++++------ src/App/Views/PrefsPage.xaml | 67 +++--- src/App/Views/PrefsPage.xaml.cs | 38 ++-- src/App/app.manifest | 2 +- src/Infrastructure/Gui/PrefsElevation.cs | 31 ++- src/Infrastructure/Gui/SettingsPayload.cs | 214 ++++++++++++++++++ .../Infrastructure/GuiSupportTests.cs | 21 +- .../Infrastructure/PolicyTemplateTests.cs | 2 +- .../Infrastructure/SettingsPayloadTests.cs | 123 ++++++++++ 13 files changed, 574 insertions(+), 157 deletions(-) create mode 100644 src/App/Program.cs create mode 100644 src/Infrastructure/Gui/SettingsPayload.cs create mode 100644 tests/StartSet.Tests/Infrastructure/SettingsPayloadTests.cs diff --git a/README.md b/README.md index bd28779..a832dd1 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Each setting is read from the first of these that sets it: 1. A one-off flag for this run (`--verbose`, `--debug`). 2. Policy: `HKLM\SOFTWARE\Policies\StartSet`, for Group Policy or MDM. -3. Machine settings: `HKLM\SOFTWARE\StartSet\Settings`, written by `managedstatekeeper add-ignored-user` and the other settings commands, or by an administrator. +3. Machine settings: `HKLM\SOFTWARE\StartSet\Settings`, written by `managedstatekeeper add-ignored-user` and the other settings commands, or from the Prefs tab in Managed State Keeper, which asks for administrator approval when you save. 4. The legacy `C:\ProgramData\ManagedState\Config.yaml`. 5. The built-in default. @@ -150,7 +150,7 @@ Every setting except `ManifestSigningKey`, which only policy can set, can be set | `Overrides` | `overrides` | empty | | `ManifestSigningKey` | policy only | unset; see [Script Signing](#script-signing) | -`resources/StartSet.admx` with `resources/en-US/StartSet.adml` is an administrative template for every setting above, for Group Policy (copy them to `C:\Windows\PolicyDefinitions` or the central store) or Intune (Imported Administrative templates). Each policy writes the value of the same name under `HKLM\SOFTWARE\Policies\StartSet`, and Managed State Keeper shows it as managed by policy and locks the field. The template's Security category also carries `ManifestSigningKey`, which StartSet reads from policy only and does not show on the Prefs tab. +`resources/StartSet.admx` with `resources/en-US/StartSet.adml` is an administrative template for every setting above, for Group Policy (copy them to `C:\Windows\PolicyDefinitions` or the central store) or Intune (Imported Administrative templates). Each policy writes the value of the same name under `HKLM\SOFTWARE\Policies\StartSet`, and Managed State Keeper shows that field grayed out, under one line saying gray settings are configured by a policy. The template's Security category also carries `ManifestSigningKey`, which StartSet reads from policy only and does not show on the Prefs tab. To set a timeout by policy without the template: diff --git a/src/App/MainWindow.xaml.cs b/src/App/MainWindow.xaml.cs index 1deb50b..ee350a6 100644 --- a/src/App/MainWindow.xaml.cs +++ b/src/App/MainWindow.xaml.cs @@ -44,9 +44,8 @@ public MainWindow() var args = Environment.GetCommandLineArgs().Skip(1).ToArray(); - // Open on Prefs when asked to (the elevated relaunch from Unlock passes --prefs); - // --tab run|logs opens another tab. - var tab = PrefsElevation.OpensOnPrefs(args) ? "prefs" : TabArgument(args) ?? "prefs"; + // Open on Prefs; --tab run|logs opens another tab. + var tab = TabArgument(args) ?? "prefs"; NavView.SelectedItem = NavView.MenuItems.OfType() .FirstOrDefault(i => (string?)i.Tag == tab) ?? NavView.MenuItems[0]; } diff --git a/src/App/Program.cs b/src/App/Program.cs new file mode 100644 index 0000000..9733b2e --- /dev/null +++ b/src/App/Program.cs @@ -0,0 +1,28 @@ +using StartSet.Infrastructure.Gui; + +namespace StartSet.App; + +/// +/// Entry point. Replaces the XAML-generated Main so that the elevated, headless half of the +/// Prefs tab's Save (--apply-settings) writes machine settings and exits before WinUI starts +/// and without ever creating a window. +/// +public static class Program +{ + [STAThread] + public static int Main(string[] args) + { + if (SettingsPayload.IsApplyRun(args)) + return SettingsPayload.RunHeadless(args); + + WinRT.ComWrappersSupport.InitializeComWrappers(); + Microsoft.UI.Xaml.Application.Start(_ => + { + var context = new Microsoft.UI.Dispatching.DispatcherQueueSynchronizationContext( + Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()); + SynchronizationContext.SetSynchronizationContext(context); + new App(); + }); + return 0; + } +} diff --git a/src/App/StartSet.App.csproj b/src/App/StartSet.App.csproj index eaf9384..a0c7ede 100644 --- a/src/App/StartSet.App.csproj +++ b/src/App/StartSet.App.csproj @@ -28,6 +28,8 @@ generates resources.pri with makepri.exe from the Windows SDK instead. --> false $(NoWarn);MVVMTK0045 + + $(DefineConstants);DISABLE_XAML_GENERATED_MAIN diff --git a/src/App/ViewModels/PrefsViewModel.cs b/src/App/ViewModels/PrefsViewModel.cs index 5a8f30e..273a609 100644 --- a/src/App/ViewModels/PrefsViewModel.cs +++ b/src/App/ViewModels/PrefsViewModel.cs @@ -50,37 +50,21 @@ public sealed class SettingItem /// /// ViewModel for the Prefs tab. Every StartSet setting is shown with its effective value and -/// source. Settings live in HKLM, so the tab is read-only unless this process is elevated; -/// Unlock relaunches the app elevated on this tab. A setting policy manages shows the policy -/// value and stays locked. Elevated edits save to HKLM\SOFTWARE\StartSet\Settings. +/// source. A setting policy sets shows the policy value and cannot be edited; every other one +/// can, in the normal app. Edits are held until Save, which writes them to +/// HKLM\SOFTWARE\StartSet\Settings: directly when this process is elevated, otherwise through +/// an elevated, windowless run of this app that UAC asks the user to approve. /// public partial class PrefsViewModel : ObservableObject { - public static readonly string[] LogLevels = ["", "Debug", "Information", "Warning", "Error"]; - - private readonly Dictionary _pendingSaves = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _pending = new(StringComparer.OrdinalIgnoreCase); private PreferencesService _service = new(); public List Items { get; private set; } = []; - // ── Elevation State ───────────────────────────────────────── - - /// True when this process can write HKLM (elevated administrator token). + /// True when this process can write HKLM itself, so Save needs no UAC prompt. public bool IsElevated { get; } = PrefsElevation.IsProcessElevated(); - public bool IsReadOnly => !IsElevated; - - [ObservableProperty] private string _unlockError = ""; - - public bool HasUnlockError => !string.IsNullOrEmpty(UnlockError); - - partial void OnUnlockErrorChanged(string value) => OnPropertyChanged(nameof(HasUnlockError)); - - // ── Save Status ────────────────────────────────────────────── - - [ObservableProperty] private string _saveMessage = ""; - [ObservableProperty] private bool _saveFailed; - public int ManagedCount => Items.Count(i => i.IsManaged); /// True when policy sets at least one value, which shows the policy line at the top. @@ -111,6 +95,9 @@ public string VersionDisplay // ── Load ───────────────────────────────────────────────────── + /// Raised after the settings are read again, so the page rebuilds its cards. + public event Action? Reloaded; + public void Load() { _service = new PreferencesService(); @@ -127,7 +114,7 @@ public void Load() Kind = KindOf(setting), Value = setting.Property.GetValue(preferences), Source = source, - CanEdit = PrefsElevation.CanEdit(IsElevated, source == SettingSource.Policy || setting.PolicyOnly), + CanEdit = PrefsElevation.CanEdit(source == SettingSource.Policy || setting.PolicyOnly), }; }) .ToList(); @@ -135,6 +122,7 @@ public void Load() OnPropertyChanged(nameof(Items)); OnPropertyChanged(nameof(ManagedCount)); OnPropertyChanged(nameof(HasPolicySettings)); + Reloaded?.Invoke(); } private static SettingKind KindOf(PreferenceSetting setting) @@ -147,79 +135,149 @@ private static SettingKind KindOf(PreferenceSetting setting) return SettingKind.Text; } - // ── Save ───────────────────────────────────────────────────── + // ── Pending edits ──────────────────────────────────────────── + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingChanges))] + [NotifyPropertyChangedFor(nameof(PendingSummary))] + private int _pendingCount; + + [ObservableProperty] private bool _isSaving; + + public bool HasPendingChanges => PendingCount > 0; + + public string PendingSummary => PendingCount == 1 ? "1 unsaved change" : $"{PendingCount} unsaved changes"; /// - /// Saves one edited value to machine settings after a short pause, so typing in a field - /// writes once. Only an elevated process gets here; a policy-managed setting is never - /// editable, and the service refuses one anyway. + /// Holds an edited value until Save. A value that breaks its setting's rules is reported and + /// not held; one changed back to what is saved is no longer pending. /// - public void QueueSave(SettingItem item, object? value) + public void SetPending(SettingItem item, object? value) { if (!item.CanEdit) return; - if (_pendingSaves.Remove(item.Name, out var existing)) - existing.Dispose(); - - _pendingSaves[item.Name] = new System.Threading.Timer(_ => + try { - try - { - _service.SaveMachineSetting(item.Name, Normalize(item, value)); - Report($"Saved {item.Description.Label}", failed: false); - } - catch (Exception ex) - { - Report($"Could not save {item.Description.Label}: {ex.Message}", failed: true); - } - }, null, 600, System.Threading.Timeout.Infinite); + var normalized = SettingsPayload.Normalize(item.Setting, value); + if (SameValue(item, normalized)) + _pending.Remove(item.Name); + else + _pending[item.Name] = normalized; + } + catch (ArgumentException) + { + _pending.Remove(item.Name); + Report($"Not saved: {item.Description.Label} is not a valid value.", failed: true); + } + PendingCount = _pending.Count; } - /// Raised on the UI thread's behalf; the page marshals it. - public event Action? SaveReported; + private static bool SameValue(SettingItem item, object normalized) + { + object saved; + try { saved = SettingsPayload.Normalize(item.Setting, item.Value); } + catch (ArgumentException) { return false; } - private void Report(string message, bool failed) => SaveReported?.Invoke(message, failed); + return saved is List a && normalized is List b + ? a.SequenceEqual(b) + : Equals(saved, normalized); + } - private static object Normalize(SettingItem item, object? value) => item.Kind switch + /// Drops every unsaved edit and shows the saved values again. + public void Discard() { - SettingKind.Toggle => value is true, - SettingKind.Number => value is double d && !double.IsNaN(d) ? (int)Math.Round(d) : 0, - SettingKind.List => (value as string ?? string.Empty) - .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .ToList(), - _ => value as string ?? string.Empty - }; + _pending.Clear(); + PendingCount = 0; + Load(); + } - // ── Unlock ─────────────────────────────────────────────────── + // ── Save ───────────────────────────────────────────────────── /// - /// Relaunches this app elevated through UAC, opening on the Prefs tab. Returns true when the - /// elevated instance started, so the caller can close this one. A cancelled UAC prompt - /// returns false quietly and leaves the tab read-only. + /// Writes the pending edits to machine settings. An elevated process writes them itself; + /// otherwise this app's executable is started elevated with the edits in a temp file, and + /// UAC asks for approval. A declined prompt keeps the edits pending. /// - public bool TryRelaunchElevated() + public async Task SaveAsync() { - UnlockError = ""; + if (_pending.Count == 0 || IsSaving) return; + IsSaving = true; try { - var exe = Environment.ProcessPath; - if (string.IsNullOrEmpty(exe)) + var values = new Dictionary(_pending, StringComparer.OrdinalIgnoreCase); + var failure = IsElevated ? await SaveDirectAsync(values) : await SaveElevatedAsync(values); + if (failure is not null) { - UnlockError = "Could not find the app's own executable to relaunch."; - return false; + Report(failure, failed: true); + return; } - using var process = System.Diagnostics.Process.Start(PrefsElevation.BuildElevatedRelaunch(exe)); - return process is not null; + _pending.Clear(); + PendingCount = 0; + Load(); + Report(values.Count == 1 ? "Saved 1 setting" : $"Saved {values.Count} settings", failed: false); + } + finally + { + IsSaving = false; + } + } + + private async Task SaveDirectAsync(Dictionary values) + { + try + { + await Task.Run(() => + { + var service = new PreferencesService(); + service.Load(); + SettingsPayload.Apply(values, service); + }); + return null; + } + catch (Exception ex) + { + return $"Not saved: {ex.Message}"; + } + } + + private static async Task SaveElevatedAsync(Dictionary values) + { + var exe = Environment.ProcessPath; + if (string.IsNullOrEmpty(exe)) + return "Not saved: could not find the app's own executable."; + + string? payload = null; + try + { + payload = SettingsPayload.WriteTempFile(values); + using var process = System.Diagnostics.Process.Start(PrefsElevation.BuildElevatedApply(exe, payload)); + if (process is null) + return "Not saved: the settings writer did not start."; + await process.WaitForExitAsync(); + return process.ExitCode == SettingsPayload.ExitSuccess + ? null + : $"Not saved: {SettingsPayload.ExitCodeText(process.ExitCode)}."; } catch (Exception ex) when (PrefsElevation.IsElevationCancelled(ex)) { - return false; + return "Not saved: administrator approval was declined."; } catch (Exception ex) { - UnlockError = $"Could not relaunch as administrator: {ex.Message}"; - return false; + return $"Not saved: {ex.Message}"; + } + finally + { + if (payload is not null) + { + try { File.Delete(payload); } catch { } + } } } + + /// Raised with a status line for the page to show. + public event Action? SaveReported; + + private void Report(string message, bool failed) => SaveReported?.Invoke(message, failed); } diff --git a/src/App/Views/PrefsPage.xaml b/src/App/Views/PrefsPage.xaml index 49b9bd4..27a8c24 100644 --- a/src/App/Views/PrefsPage.xaml +++ b/src/App/Views/PrefsPage.xaml @@ -45,48 +45,35 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> - - - - - - - - - - - - - - + + + + + + + + + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Visibility="{x:Bind ViewModel.HasPendingChanges, Mode=OneWay, Converter={StaticResource BoolToVisibility}}" /> + - - - - - - - - + + diff --git a/src/App/Views/PrefsPage.xaml.cs b/src/App/Views/PrefsPage.xaml.cs index 26aa9cf..c1b02b7 100644 --- a/src/App/Views/PrefsPage.xaml.cs +++ b/src/App/Views/PrefsPage.xaml.cs @@ -4,6 +4,7 @@ using Microsoft.UI.Xaml.Navigation; using StartSet.App.ViewModels; using StartSet.Infrastructure.Configuration; +using StartSet.Infrastructure.Gui; namespace StartSet.App.Views; @@ -30,24 +31,33 @@ public PrefsPage() : (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]; SaveStatus.Visibility = Visibility.Visible; }); + ViewModel.Reloaded += BuildCards; + + // An elevated app saves without a UAC prompt, so its Save button carries no shield. + SaveShield.Visibility = ViewModel.IsElevated ? Visibility.Collapsed : Visibility.Visible; } protected override void OnNavigatedTo(NavigationEventArgs e) { base.OnNavigatedTo(e); - ViewModel.Load(); - BuildCards(); + // Coming back to the cached page keeps unsaved edits; otherwise read the settings again. + if (!ViewModel.HasPendingChanges) + ViewModel.Load(); _ = ViewModel.RefreshServicesAsync(); } private void RefreshServices_Click(object sender, RoutedEventArgs e) => _ = ViewModel.RefreshServicesAsync(); - private void UnlockButton_Click(object sender, RoutedEventArgs e) + private async void Save_Click(object sender, RoutedEventArgs e) + { + SaveStatus.Visibility = Visibility.Collapsed; + await ViewModel.SaveAsync(); + } + + private void Discard_Click(object sender, RoutedEventArgs e) { - // Once the elevated copy has started (UAC accepted), this read-only instance closes. - // A cancelled UAC prompt returns false and the tab simply stays read-only. - if (ViewModel.TryRelaunchElevated()) - Application.Current.Exit(); + SaveStatus.Visibility = Visibility.Collapsed; + ViewModel.Discard(); } // ── Card construction ─────────────────────────────────────── @@ -153,7 +163,7 @@ private Control Editor(SettingItem item) case SettingKind.Toggle: { var toggle = new ToggleSwitch { IsOn = item.BoolValue, OnContent = "On", OffContent = "Off", HorizontalAlignment = HorizontalAlignment.Right }; - toggle.Toggled += (_, _) => ViewModel.QueueSave(item, toggle.IsOn); + toggle.Toggled += (_, _) => ViewModel.SetPending(item, toggle.IsOn); return toggle; } case SettingKind.Number: @@ -165,17 +175,17 @@ private Control Editor(SettingItem item) SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact, PlaceholderText = item.Description.Unit ?? string.Empty }; - number.ValueChanged += (_, args) => ViewModel.QueueSave(item, args.NewValue); + number.ValueChanged += (_, args) => ViewModel.SetPending(item, args.NewValue); return number; } case SettingKind.Choice: { var combo = new ComboBox { HorizontalAlignment = HorizontalAlignment.Stretch }; - foreach (var level in PrefsViewModel.LogLevels) + foreach (var level in SettingsPayload.LogLevels) combo.Items.Add(level.Length == 0 ? "(not set)" : level); - var index = Array.FindIndex(PrefsViewModel.LogLevels, l => string.Equals(l, item.TextValue, StringComparison.OrdinalIgnoreCase)); + var index = Array.FindIndex(SettingsPayload.LogLevels, l => string.Equals(l, item.TextValue, StringComparison.OrdinalIgnoreCase)); combo.SelectedIndex = Math.Max(0, index); - combo.SelectionChanged += (_, _) => ViewModel.QueueSave(item, PrefsViewModel.LogLevels[Math.Max(0, combo.SelectedIndex)]); + combo.SelectionChanged += (_, _) => ViewModel.SetPending(item, SettingsPayload.LogLevels[Math.Max(0, combo.SelectedIndex)]); return combo; } case SettingKind.List: @@ -190,13 +200,13 @@ private Control Editor(SettingItem item) PlaceholderText = "None" }; ScrollViewer.SetVerticalScrollBarVisibility(box, ScrollBarVisibility.Auto); - box.TextChanged += (_, _) => ViewModel.QueueSave(item, box.Text); + box.TextChanged += (_, _) => ViewModel.SetPending(item, box.Text); return box; } default: { var box = new TextBox { Text = item.TextValue }; - box.TextChanged += (_, _) => ViewModel.QueueSave(item, box.Text); + box.TextChanged += (_, _) => ViewModel.SetPending(item, box.Text); return box; } } diff --git a/src/App/app.manifest b/src/App/app.manifest index 8f665a7..4437f8e 100644 --- a/src/App/app.manifest +++ b/src/App/app.manifest @@ -4,7 +4,7 @@ - + diff --git a/src/Infrastructure/Gui/PrefsElevation.cs b/src/Infrastructure/Gui/PrefsElevation.cs index d4b094d..2369314 100644 --- a/src/Infrastructure/Gui/PrefsElevation.cs +++ b/src/Infrastructure/Gui/PrefsElevation.cs @@ -5,27 +5,18 @@ namespace StartSet.Infrastructure.Gui; /// -/// Decisions behind the GUI's read-only Prefs tab. Settings live in HKLM, so they can only be -/// changed by an elevated process: a non-elevated GUI shows them read-only and "Unlock" -/// relaunches the app itself through UAC, opening straight on the Prefs tab. There is no -/// SYSTEM service and no user-writable file in the path. +/// Decisions behind the Prefs tab's editing. Every setting policy does not set can be edited +/// in the normal, unelevated app; the edits are held until Save. Settings live in HKLM, so a +/// non-elevated app saves by starting its own executable elevated through UAC with +/// , which writes them headless and exits. /// public static class PrefsElevation { - /// Command-line switch that opens the GUI on the Prefs tab. - public const string PrefsArgument = "--prefs"; - /// Win32 ERROR_CANCELLED: the user dismissed the UAC prompt. public const int ErrorCancelled = 1223; - /// True when the command line asks the GUI to open on the Prefs tab. - public static bool OpensOnPrefs(IEnumerable? args) - => args?.Any(a => string.Equals(a?.Trim(), PrefsArgument, StringComparison.OrdinalIgnoreCase)) == true; - - /// - /// A setting can be edited only by an elevated process, and never when policy manages it. - /// - public static bool CanEdit(bool isElevated, bool isPolicyManaged) => isElevated && !isPolicyManaged; + /// A setting can be edited unless policy sets it. + public static bool CanEdit(bool isPolicyManaged) => !isPolicyManaged; /// True when the exception is the user cancelling the UAC prompt, not a real failure. public static bool IsElevationCancelled(Exception? ex) @@ -48,14 +39,18 @@ public static bool IsProcessElevated() } } - /// Start info that relaunches elevated on the Prefs tab. - public static ProcessStartInfo BuildElevatedRelaunch(string exePath) + /// + /// Start info that runs elevated to write the settings in + /// to machine settings, with no window. + /// + public static ProcessStartInfo BuildElevatedApply(string exePath, string payloadPath) { ArgumentException.ThrowIfNullOrWhiteSpace(exePath); + ArgumentException.ThrowIfNullOrWhiteSpace(payloadPath); return new ProcessStartInfo { FileName = exePath, - Arguments = PrefsArgument, + Arguments = $"{SettingsPayload.ApplyArgument} \"{payloadPath}\"", UseShellExecute = true, Verb = "runas", WorkingDirectory = Path.GetDirectoryName(exePath) ?? string.Empty, diff --git a/src/Infrastructure/Gui/SettingsPayload.cs b/src/Infrastructure/Gui/SettingsPayload.cs new file mode 100644 index 0000000..d959cd0 --- /dev/null +++ b/src/Infrastructure/Gui/SettingsPayload.cs @@ -0,0 +1,214 @@ +using System.Text.Json; +using StartSet.Infrastructure.Configuration; + +namespace StartSet.Infrastructure.Gui; + +/// What a headless apply wrote and what it left alone. +public sealed record ApplyResult(IReadOnlyList Written, IReadOnlyList Skipped); + +/// +/// The Prefs tab's unsaved edits, and how they reach machine settings. +/// +/// +/// A non-elevated app cannot write HKLM, so on Save it writes the edits to a JSON file in the +/// user's temp folder and starts its own executable elevated with +/// and that file. The elevated run reads policy again, skips every setting policy sets, checks +/// each value with the same rules the Prefs tab uses, writes the rest and exits with no window. +/// An already elevated app calls directly. +/// +public static class SettingsPayload +{ + /// Command-line switch that writes a payload file to machine settings and exits. + public const string ApplyArgument = "--apply-settings"; + + /// The LogLevel choices on the Prefs tab; empty is "not set". + public static readonly string[] LogLevels = ["", "Debug", "Information", "Warning", "Error"]; + + public const int ExitSuccess = 0; + public const int ExitBadPayload = 2; + public const int ExitWriteFailed = 3; + + /// The headless writer's exit code in words, for the Prefs tab's status line. + public static string ExitCodeText(int code) => code switch + { + ExitSuccess => "Saved", + ExitBadPayload => "the settings to save could not be read", + ExitWriteFailed => "machine settings could not be written", + _ => $"the settings writer exited with code {code}" + }; + + // ── Validation ────────────────────────────────────────────── + + /// + /// as the type stores, checked with the + /// Prefs tab's rules: whole numbers of zero or more, one line per list entry, and a known + /// log level. Throws for anything else. + /// + public static object Normalize(PreferenceSetting setting, object? value) + { + var type = Nullable.GetUnderlyingType(setting.ValueType) ?? setting.ValueType; + + if (type == typeof(bool)) + return value is bool b ? b : throw Invalid(setting, "must be on or off"); + + if (type == typeof(int)) + { + double? number = value switch + { + int i => i, + long l => l, + double d => d, + _ => null + }; + if (number is not { } n || double.IsNaN(n) || n < 0 || n > int.MaxValue || n != Math.Floor(n)) + throw Invalid(setting, "must be a whole number of zero or more"); + return (int)n; + } + + if (type == typeof(List)) + { + var lines = value switch + { + string text => text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + IEnumerable list => list.Select(s => s?.Trim() ?? string.Empty).Where(s => s.Length > 0), + null => [], + _ => throw Invalid(setting, "must be a list of lines") + }; + return lines.ToList(); + } + + var textValue = value switch + { + string s => s, + null => string.Empty, + _ => throw Invalid(setting, "must be text") + }; + if (setting.Name == "LogLevel") + { + var level = LogLevels.FirstOrDefault(l => string.Equals(l, textValue.Trim(), StringComparison.OrdinalIgnoreCase)) + ?? throw Invalid(setting, $"must be one of {string.Join(", ", LogLevels.Where(l => l.Length > 0))}, or empty"); + return level; + } + return textValue; + } + + private static ArgumentException Invalid(PreferenceSetting setting, string rule) => + new($"{setting.Name} {rule}"); + + // ── Payload file ──────────────────────────────────────────── + + /// The edits as JSON, one property per setting name. + public static string Serialize(IReadOnlyDictionary values) => + JsonSerializer.Serialize(values, new JsonSerializerOptions { WriteIndented = true }); + + /// + /// Reads a payload back, each value checked and typed for its setting. Throws + /// for a name that is not a setting or a value that + /// breaks its setting's rules. + /// + public static Dictionary Deserialize(string json) + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("The settings payload is not a JSON object"); + + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in document.RootElement.EnumerateObject()) + { + var setting = PreferenceResolver.Find(property.Name) + ?? throw new InvalidDataException($"Unknown setting: {property.Name}"); + try + { + values[setting.Name] = Normalize(setting, FromJson(property.Value)); + } + catch (ArgumentException ex) + { + throw new InvalidDataException(ex.Message, ex); + } + } + return values; + } + + private static object? FromJson(JsonElement element) => element.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Array => element.EnumerateArray() + .Select(e => e.ValueKind == JsonValueKind.String ? e.GetString()! : throw new InvalidDataException("List entries must be text")) + .ToList(), + JsonValueKind.Null => null, + _ => throw new InvalidDataException($"Unsupported value: {element.ValueKind}") + }; + + /// Writes the edits to a new file in the user's temp folder and returns its path. + public static string WriteTempFile(IReadOnlyDictionary values) + { + var path = Path.Combine(Path.GetTempPath(), $"startset-settings-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, Serialize(values)); + return path; + } + + // ── Writing ───────────────────────────────────────────────── + + /// + /// Writes each value to machine settings through , skipping any + /// setting policy sets or that only policy may set. must have + /// been loaded so it sees the current policy. + /// + public static ApplyResult Apply(IReadOnlyDictionary values, PreferencesService service) + { + var written = new List(); + var skipped = new List(); + foreach (var (name, value) in values) + { + var setting = PreferenceResolver.Find(name); + if (setting is null || setting.PolicyOnly || service.IsManaged(setting.Name)) + { + skipped.Add(name); + continue; + } + service.SaveMachineSetting(setting.Name, Normalize(setting, value)); + written.Add(setting.Name); + } + return new ApplyResult(written, skipped); + } + + /// + /// The elevated, windowless half of Save: reads the payload named after + /// , writes it with the real policy and machine settings, and + /// returns the process exit code. + /// + public static int RunHeadless(IReadOnlyList args, Func? serviceFactory = null) + { + Dictionary values; + try + { + var index = args.ToList().FindIndex(a => string.Equals(a, ApplyArgument, StringComparison.OrdinalIgnoreCase)); + if (index < 0 || index + 1 >= args.Count) + return ExitBadPayload; + values = Deserialize(File.ReadAllText(args[index + 1])); + } + catch (Exception) + { + return ExitBadPayload; + } + + try + { + var service = serviceFactory?.Invoke() ?? new PreferencesService(); + service.Load(); + Apply(values, service); + return ExitSuccess; + } + catch (Exception) + { + return ExitWriteFailed; + } + } + + /// True when the command line asks for a headless apply. + public static bool IsApplyRun(IEnumerable? args) + => args?.Any(a => string.Equals(a, ApplyArgument, StringComparison.OrdinalIgnoreCase)) == true; +} diff --git a/tests/StartSet.Tests/Infrastructure/GuiSupportTests.cs b/tests/StartSet.Tests/Infrastructure/GuiSupportTests.cs index ad63d6a..87280c9 100644 --- a/tests/StartSet.Tests/Infrastructure/GuiSupportTests.cs +++ b/tests/StartSet.Tests/Infrastructure/GuiSupportTests.cs @@ -32,24 +32,25 @@ public void EveryDescription_IsInAKnownGroup() .Should().OnlyContain(group => SettingDescriptions.Groups.Contains(group)); } - [Theory] - [InlineData(false, false, false)] - [InlineData(false, true, false)] - [InlineData(true, true, false)] - [InlineData(true, false, true)] - public void A_Setting_IsEditable_OnlyElevated_AndNotManaged(bool elevated, bool managed, bool expected) + [Fact] + public void A_ManagedSetting_IsNotEditable() => PrefsElevation.CanEdit(isPolicyManaged: true).Should().BeFalse(); + + [Fact] + public void An_UnmanagedSetting_IsEditable_WithoutElevation() { - PrefsElevation.CanEdit(elevated, managed).Should().Be(expected); + // Editability no longer depends on elevation: UAC is asked for at Save. + PrefsElevation.CanEdit(isPolicyManaged: false).Should().BeTrue(); } [Fact] - public void Unlock_RelaunchesElevated_OnThePrefsTab() + public void Save_StartsTheAppElevated_ToApplyThePayload() { - var start = PrefsElevation.BuildElevatedRelaunch(@"C:\Program Files\StartSet\Managed State Keeper.exe"); + var start = PrefsElevation.BuildElevatedApply(@"C:\Program Files\StartSet\Managed State Keeper.exe", @"C:\Temp\s.json"); start.Verb.Should().Be("runas"); start.UseShellExecute.Should().BeTrue(); - PrefsElevation.OpensOnPrefs([start.Arguments]).Should().BeTrue(); + start.Arguments.Should().Be(@"--apply-settings ""C:\Temp\s.json"""); + SettingsPayload.IsApplyRun(start.Arguments.Split(' ')).Should().BeTrue(); start.WorkingDirectory.Should().Be(@"C:\Program Files\StartSet"); } diff --git a/tests/StartSet.Tests/Infrastructure/PolicyTemplateTests.cs b/tests/StartSet.Tests/Infrastructure/PolicyTemplateTests.cs index 606f336..afc1495 100644 --- a/tests/StartSet.Tests/Infrastructure/PolicyTemplateTests.cs +++ b/tests/StartSet.Tests/Infrastructure/PolicyTemplateTests.cs @@ -138,7 +138,7 @@ public void EverySetting_SetTheWayThePolicyWritesIt_IsManagedAndLocked(string na svc.Sources[name].Should().Be(SettingSource.Policy, $"{name} written as the ADMX writes it must be read as policy"); svc.IsManaged(name).Should().BeTrue(); - PrefsElevation.CanEdit(isElevated: true, isPolicyManaged: svc.Sources[name] == SettingSource.Policy).Should().BeFalse(); + PrefsElevation.CanEdit(isPolicyManaged: svc.Sources[name] == SettingSource.Policy).Should().BeFalse(); } [Fact] diff --git a/tests/StartSet.Tests/Infrastructure/SettingsPayloadTests.cs b/tests/StartSet.Tests/Infrastructure/SettingsPayloadTests.cs new file mode 100644 index 0000000..00bcf01 --- /dev/null +++ b/tests/StartSet.Tests/Infrastructure/SettingsPayloadTests.cs @@ -0,0 +1,123 @@ +using FluentAssertions; +using StartSet.Infrastructure.Configuration; +using StartSet.Infrastructure.Gui; +using StartSet.Tests.Helpers; + +namespace StartSet.Tests.Infrastructure; + +/// The Prefs tab's Save: the edits payload and the headless writer. +public class SettingsPayloadTests : IDisposable +{ + private readonly TempDirectory _temp = new(); + + public void Dispose() => _temp.Dispose(); + + private static PreferenceSetting Setting(string name) => PreferenceResolver.Find(name)!; + + private string MissingConfig => Path.Combine(_temp.Path, "Config.yaml"); + + [Fact] + public void Payload_RoundTrips_EveryKindOfValue() + { + var values = new Dictionary + { + ["WaitForNetwork"] = false, + ["NetworkTimeout"] = 60, + ["LogLevel"] = "Warning", + ["IgnoredUsers"] = new List { "kiosk", "lab" }, + }; + + var back = SettingsPayload.Deserialize(SettingsPayload.Serialize(values)); + + back.Should().HaveCount(4); + back["WaitForNetwork"].Should().Be(false); + back["NetworkTimeout"].Should().Be(60); + back["LogLevel"].Should().Be("Warning"); + back["IgnoredUsers"].Should().BeEquivalentTo(new List { "kiosk", "lab" }, o => o.WithStrictOrdering()); + } + + [Theory] + [InlineData("""{ "NoSuchSetting": 1 }""")] + [InlineData("""{ "NetworkTimeout": -5 }""")] + [InlineData("""{ "NetworkTimeout": 1.5 }""")] + [InlineData("""{ "WaitForNetwork": "yes" }""")] + [InlineData("""{ "LogLevel": "Chatty" }""")] + [InlineData("""[ 1, 2 ]""")] + public void Payload_RejectsWhatThePrefsTabWouldNot(string json) + { + var read = () => SettingsPayload.Deserialize(json); + read.Should().Throw(); + } + + [Theory] + [InlineData(" warning ", "Warning")] + [InlineData("", "")] + public void LogLevel_IsMatchedToAKnownLevel(string input, string expected) => + SettingsPayload.Normalize(Setting("LogLevel"), input).Should().Be(expected); + + [Fact] + public void ListText_IsOneEntryPerLine() => + SettingsPayload.Normalize(Setting("IgnoredUsers"), " a \r\n\r\nb\n").Should().BeEquivalentTo(new List { "a", "b" }); + + [Fact] + public void NumberBoxValue_IsAWholeNumber() => + SettingsPayload.Normalize(Setting("NetworkTimeout"), 90.0).Should().Be(90); + + [Fact] + public void Apply_SkipsManagedKeys_AndWritesTheRest() + { + var policy = new InMemorySettingsStore().With("NetworkTimeout", 30); + var machine = new InMemorySettingsStore(); + var service = TestSettings.Service(policy, machine); + service.Load(MissingConfig); + + var result = SettingsPayload.Apply(new Dictionary + { + ["NetworkTimeout"] = 999, + ["Verbose"] = true, + ["ManifestSigningKey"] = "not from here", + }, service); + + result.Written.Should().Equal("Verbose"); + result.Skipped.Should().BeEquivalentTo("NetworkTimeout", "ManifestSigningKey"); + machine.Values.Should().ContainKey("Verbose").And.NotContainKey("NetworkTimeout").And.NotContainKey("ManifestSigningKey"); + } + + [Fact] + public void RunHeadless_WritesThePayloadFile_AndRereadsPolicy() + { + var policy = new InMemorySettingsStore().With("Verbose", 0); + var machine = new InMemorySettingsStore(); + var path = Path.Combine(_temp.Path, "payload.json"); + File.WriteAllText(path, SettingsPayload.Serialize(new Dictionary + { + ["Verbose"] = true, + ["ScriptTimeout"] = 600, + })); + + var code = SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument, path], () => TestSettings.Service(policy, machine)); + + code.Should().Be(SettingsPayload.ExitSuccess); + machine.Values.Should().ContainKey("ScriptTimeout").WhoseValue.Should().Be(600); + machine.Values.Should().NotContainKey("Verbose", "policy sets it"); + } + + [Fact] + public void RunHeadless_FailsOnAMissingOrBadPayload() + { + var bad = Path.Combine(_temp.Path, "bad.json"); + File.WriteAllText(bad, "not json"); + + SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument], () => TestSettings.Service()).Should().Be(SettingsPayload.ExitBadPayload); + SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument, Path.Combine(_temp.Path, "missing.json")], () => TestSettings.Service()) + .Should().Be(SettingsPayload.ExitBadPayload); + SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument, bad], () => TestSettings.Service()).Should().Be(SettingsPayload.ExitBadPayload); + } + + [Fact] + public void ExitCodes_ReadAsReasons() + { + SettingsPayload.ExitCodeText(SettingsPayload.ExitWriteFailed).Should().Be("machine settings could not be written"); + SettingsPayload.ExitCodeText(42).Should().Be("the settings writer exited with code 42"); + } +} From 897ec5c3ce343dbe78511c48c08ef7ede46101ac Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 12 Oct 2026 23:49:22 -0700 Subject: [PATCH 3/4] Send unsaved settings to the elevated writer over a named pipe, not a temp file Save no longer writes the pending edits to a JSON file in %TEMP%. The app opens a named pipe with a random name, created as the first instance so nothing can hold the name first, and with an ACL that admits only the current user, Administrators and SYSTEM. It passes the pipe name to the elevated --apply-settings run, writes the JSON when that run connects, and closes the pipe. Nothing touches disk, and the pipe also reaches an administrator who approves UAC under a different account. Save stops waiting after 60 seconds if nothing connects, or as soon as the writer exits without connecting. --- src/App/ViewModels/PrefsViewModel.cs | 26 +++-- src/Infrastructure/Gui/PrefsElevation.cs | 10 +- src/Infrastructure/Gui/SettingsPayload.cs | 103 +++++++++++++++--- .../Infrastructure/GuiSupportTests.cs | 4 +- .../Infrastructure/SettingsPayloadTests.cs | 50 +++++++-- 5 files changed, 150 insertions(+), 43 deletions(-) diff --git a/src/App/ViewModels/PrefsViewModel.cs b/src/App/ViewModels/PrefsViewModel.cs index 273a609..53302ee 100644 --- a/src/App/ViewModels/PrefsViewModel.cs +++ b/src/App/ViewModels/PrefsViewModel.cs @@ -195,7 +195,7 @@ public void Discard() /// /// Writes the pending edits to machine settings. An elevated process writes them itself; - /// otherwise this app's executable is started elevated with the edits in a temp file, and + /// otherwise this app's executable is started elevated and sent the edits over a pipe, and /// UAC asks for approval. A declined prompt keeps the edits pending. /// public async Task SaveAsync() @@ -247,14 +247,23 @@ await Task.Run(() => if (string.IsNullOrEmpty(exe)) return "Not saved: could not find the app's own executable."; - string? payload = null; try { - payload = SettingsPayload.WriteTempFile(values); - using var process = System.Diagnostics.Process.Start(PrefsElevation.BuildElevatedApply(exe, payload)); + // The edits travel over a pipe only this user, Administrators and SYSTEM may open; + // nothing is written to disk. + var pipeName = SettingsPayload.NewPipeName(); + using var pipe = SettingsPayload.CreatePipe(pipeName); + using var process = System.Diagnostics.Process.Start(PrefsElevation.BuildElevatedApply(exe, pipeName)); if (process is null) return "Not saved: the settings writer did not start."; - await process.WaitForExitAsync(); + + // A writer that exits before it connects must not leave Save waiting out the timeout. + var send = SettingsPayload.SendAsync(pipe, values, SettingsPayload.ConnectTimeout); + var exited = process.WaitForExitAsync(); + if (await Task.WhenAny(send, exited) == send) + await send; + + await exited; return process.ExitCode == SettingsPayload.ExitSuccess ? null : $"Not saved: {SettingsPayload.ExitCodeText(process.ExitCode)}."; @@ -267,13 +276,6 @@ await Task.Run(() => { return $"Not saved: {ex.Message}"; } - finally - { - if (payload is not null) - { - try { File.Delete(payload); } catch { } - } - } } /// Raised with a status line for the page to show. diff --git a/src/Infrastructure/Gui/PrefsElevation.cs b/src/Infrastructure/Gui/PrefsElevation.cs index 2369314..739eb6d 100644 --- a/src/Infrastructure/Gui/PrefsElevation.cs +++ b/src/Infrastructure/Gui/PrefsElevation.cs @@ -40,17 +40,17 @@ public static bool IsProcessElevated() } /// - /// Start info that runs elevated to write the settings in - /// to machine settings, with no window. + /// Start info that runs elevated to write the settings it reads + /// from the pipe to machine settings, with no window. /// - public static ProcessStartInfo BuildElevatedApply(string exePath, string payloadPath) + public static ProcessStartInfo BuildElevatedApply(string exePath, string pipeName) { ArgumentException.ThrowIfNullOrWhiteSpace(exePath); - ArgumentException.ThrowIfNullOrWhiteSpace(payloadPath); + ArgumentException.ThrowIfNullOrWhiteSpace(pipeName); return new ProcessStartInfo { FileName = exePath, - Arguments = $"{SettingsPayload.ApplyArgument} \"{payloadPath}\"", + Arguments = $"{SettingsPayload.ApplyArgument} {pipeName}", UseShellExecute = true, Verb = "runas", WorkingDirectory = Path.GetDirectoryName(exePath) ?? string.Empty, diff --git a/src/Infrastructure/Gui/SettingsPayload.cs b/src/Infrastructure/Gui/SettingsPayload.cs index d959cd0..96e8308 100644 --- a/src/Infrastructure/Gui/SettingsPayload.cs +++ b/src/Infrastructure/Gui/SettingsPayload.cs @@ -1,3 +1,8 @@ +using System.IO.Pipes; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; using System.Text.Json; using StartSet.Infrastructure.Configuration; @@ -10,11 +15,15 @@ public sealed record ApplyResult(IReadOnlyList Written, IReadOnlyList /// -/// A non-elevated app cannot write HKLM, so on Save it writes the edits to a JSON file in the -/// user's temp folder and starts its own executable elevated with -/// and that file. The elevated run reads policy again, skips every setting policy sets, checks -/// each value with the same rules the Prefs tab uses, writes the rest and exits with no window. -/// An already elevated app calls directly. +/// A non-elevated app cannot write HKLM, so on Save it opens a named pipe with a random name, +/// open only to this user, Administrators and SYSTEM, and starts its own executable elevated +/// with and the pipe's name. The edits go to the elevated run as +/// JSON over the pipe and never touch disk, so a value meant to stay private is not left in a +/// temp file; and a pipe, unlike data protected for the current user, also reaches an +/// administrator who approves UAC under a different account. The elevated run reads policy +/// again, skips every setting policy sets, checks each value with the same rules the Prefs +/// tab uses, writes the rest and exits with no window. An already elevated app calls +/// directly. /// public static class SettingsPayload { @@ -142,12 +151,73 @@ public static Dictionary Deserialize(string json) _ => throw new InvalidDataException($"Unsupported value: {element.ValueKind}") }; - /// Writes the edits to a new file in the user's temp folder and returns its path. - public static string WriteTempFile(IReadOnlyDictionary values) + // ── Pipe ──────────────────────────────────────────────────── + + /// How long Save waits for the elevated run to connect, which covers the UAC prompt. + public static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(60); + + /// A pipe name nobody can guess ahead of time. + public static string NewPipeName() => $"startset-settings-{Guid.NewGuid():N}"; + + /// + /// The pipe's ACL: , who creates it, and the two accounts the + /// elevated run can be -- Administrators and SYSTEM. Nobody else may open it. + /// + [SupportedOSPlatform("windows")] + public static PipeSecurity PipeSecurityFor(SecurityIdentifier currentUser) + { + var security = new PipeSecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new PipeAccessRule(currentUser, PipeAccessRights.FullControl, AccessControlType.Allow)); + security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), PipeAccessRights.ReadWrite, AccessControlType.Allow)); + security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), PipeAccessRights.ReadWrite, AccessControlType.Allow)); + return security; + } + + /// + /// Creates the sending end of the pipe. FirstPipeInstance makes creation fail if anything + /// already holds the name, so the elevated run can only reach this app. + /// + [SupportedOSPlatform("windows")] + public static NamedPipeServerStream CreatePipe(string pipeName) + { + using var identity = WindowsIdentity.GetCurrent(); + return NamedPipeServerStreamAcl.Create( + pipeName, PipeDirection.Out, maxNumberOfServerInstances: 1, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.FirstPipeInstance, inBufferSize: 0, outBufferSize: 0, + PipeSecurityFor(identity.User!)); + } + + /// + /// Waits for the elevated run to connect, then writes the edits and closes the pipe. + /// Throws when nothing connects within . + /// + public static async Task SendAsync(NamedPipeServerStream pipe, IReadOnlyDictionary values, TimeSpan timeout) + { + using var cancel = new CancellationTokenSource(timeout); + try + { + await pipe.WaitForConnectionAsync(cancel.Token); + } + catch (OperationCanceledException) + { + throw new TimeoutException("the elevated settings writer did not connect"); + } + + var bytes = Encoding.UTF8.GetBytes(Serialize(values)); + await pipe.WriteAsync(bytes); + await pipe.FlushAsync(); + pipe.WaitForPipeDrain(); + pipe.Disconnect(); + } + + /// The elevated run's end: connects to and reads the JSON. + public static string ReceiveFromPipe(string pipeName, TimeSpan timeout) { - var path = Path.Combine(Path.GetTempPath(), $"startset-settings-{Guid.NewGuid():N}.json"); - File.WriteAllText(path, Serialize(values)); - return path; + using var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.In); + pipe.Connect((int)timeout.TotalMilliseconds); + using var reader = new StreamReader(pipe, Encoding.UTF8); + return reader.ReadToEnd(); } // ── Writing ───────────────────────────────────────────────── @@ -176,11 +246,15 @@ public static ApplyResult Apply(IReadOnlyDictionary values, Pref } /// - /// The elevated, windowless half of Save: reads the payload named after + /// The elevated, windowless half of Save: reads the payload from the pipe named after /// , writes it with the real policy and machine settings, and - /// returns the process exit code. + /// returns the process exit code. and + /// stand in for the pipe and registry in tests. /// - public static int RunHeadless(IReadOnlyList args, Func? serviceFactory = null) + public static int RunHeadless( + IReadOnlyList args, + Func? serviceFactory = null, + Func? receive = null) { Dictionary values; try @@ -188,7 +262,8 @@ public static int RunHeadless(IReadOnlyList args, Func string.Equals(a, ApplyArgument, StringComparison.OrdinalIgnoreCase)); if (index < 0 || index + 1 >= args.Count) return ExitBadPayload; - values = Deserialize(File.ReadAllText(args[index + 1])); + var pipeName = args[index + 1]; + values = Deserialize(receive?.Invoke(pipeName) ?? ReceiveFromPipe(pipeName, TimeSpan.FromSeconds(10))); } catch (Exception) { diff --git a/tests/StartSet.Tests/Infrastructure/GuiSupportTests.cs b/tests/StartSet.Tests/Infrastructure/GuiSupportTests.cs index 87280c9..a0946ec 100644 --- a/tests/StartSet.Tests/Infrastructure/GuiSupportTests.cs +++ b/tests/StartSet.Tests/Infrastructure/GuiSupportTests.cs @@ -45,11 +45,11 @@ public void An_UnmanagedSetting_IsEditable_WithoutElevation() [Fact] public void Save_StartsTheAppElevated_ToApplyThePayload() { - var start = PrefsElevation.BuildElevatedApply(@"C:\Program Files\StartSet\Managed State Keeper.exe", @"C:\Temp\s.json"); + var start = PrefsElevation.BuildElevatedApply(@"C:\Program Files\StartSet\Managed State Keeper.exe", "startset-settings-abc"); start.Verb.Should().Be("runas"); start.UseShellExecute.Should().BeTrue(); - start.Arguments.Should().Be(@"--apply-settings ""C:\Temp\s.json"""); + start.Arguments.Should().Be("--apply-settings startset-settings-abc"); SettingsPayload.IsApplyRun(start.Arguments.Split(' ')).Should().BeTrue(); start.WorkingDirectory.Should().Be(@"C:\Program Files\StartSet"); } diff --git a/tests/StartSet.Tests/Infrastructure/SettingsPayloadTests.cs b/tests/StartSet.Tests/Infrastructure/SettingsPayloadTests.cs index 00bcf01..297417b 100644 --- a/tests/StartSet.Tests/Infrastructure/SettingsPayloadTests.cs +++ b/tests/StartSet.Tests/Infrastructure/SettingsPayloadTests.cs @@ -84,18 +84,20 @@ public void Apply_SkipsManagedKeys_AndWritesTheRest() } [Fact] - public void RunHeadless_WritesThePayloadFile_AndRereadsPolicy() + public async Task RunHeadless_ReadsThePipe_AndRereadsPolicy() { var policy = new InMemorySettingsStore().With("Verbose", 0); var machine = new InMemorySettingsStore(); - var path = Path.Combine(_temp.Path, "payload.json"); - File.WriteAllText(path, SettingsPayload.Serialize(new Dictionary + var pipeName = SettingsPayload.NewPipeName(); + using var pipe = SettingsPayload.CreatePipe(pipeName); + var send = SettingsPayload.SendAsync(pipe, new Dictionary { ["Verbose"] = true, ["ScriptTimeout"] = 600, - })); + }, TimeSpan.FromSeconds(10)); - var code = SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument, path], () => TestSettings.Service(policy, machine)); + var code = await Task.Run(() => SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument, pipeName], () => TestSettings.Service(policy, machine))); + await send; code.Should().Be(SettingsPayload.ExitSuccess); machine.Values.Should().ContainKey("ScriptTimeout").WhoseValue.Should().Be(600); @@ -105,13 +107,41 @@ public void RunHeadless_WritesThePayloadFile_AndRereadsPolicy() [Fact] public void RunHeadless_FailsOnAMissingOrBadPayload() { - var bad = Path.Combine(_temp.Path, "bad.json"); - File.WriteAllText(bad, "not json"); - SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument], () => TestSettings.Service()).Should().Be(SettingsPayload.ExitBadPayload); - SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument, Path.Combine(_temp.Path, "missing.json")], () => TestSettings.Service()) + SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument, "p"], () => TestSettings.Service(), _ => "not json") + .Should().Be(SettingsPayload.ExitBadPayload); + SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument, "p"], () => TestSettings.Service(), _ => throw new TimeoutException()) .Should().Be(SettingsPayload.ExitBadPayload); - SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument, bad], () => TestSettings.Service()).Should().Be(SettingsPayload.ExitBadPayload); + } + + [Fact] + public void Pipe_IsOpenOnlyToThisUser_Administrators_AndSystem() + { + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var security = SettingsPayload.PipeSecurityFor(identity.User!); + + security.AreAccessRulesProtected.Should().BeTrue("nothing may be inherited"); + security.GetAccessRules(true, true, typeof(System.Security.Principal.SecurityIdentifier)) + .Cast() + .Select(r => r.IdentityReference.Value) + .Should().BeEquivalentTo(identity.User!.Value, "S-1-5-32-544", "S-1-5-18"); + } + + [Fact] + public async Task Send_TimesOut_WhenNothingConnects() + { + using var pipe = SettingsPayload.CreatePipe(SettingsPayload.NewPipeName()); + var send = () => SettingsPayload.SendAsync(pipe, new Dictionary(), TimeSpan.FromMilliseconds(200)); + await send.Should().ThrowAsync(); + } + + [Fact] + public void A_SecondPipe_WithTheSameName_IsRefused() + { + var name = SettingsPayload.NewPipeName(); + using var first = SettingsPayload.CreatePipe(name); + var squat = () => SettingsPayload.CreatePipe(name); + squat.Should().Throw(); } [Fact] From 1e3eb45e7ad67ef267e5eafb9341fafc8babb0f8 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Tue, 13 Oct 2026 00:58:17 -0700 Subject: [PATCH 4/4] Show each installed service as an expandable row on the Services card The Services card is now an accordion. Each row is collapsed by default and shows the friendly name, one summary line (kind, start type and account), and a state dot and word on the right, coloured from theme brushes only. The Expander's header spans the whole row, so a click anywhere on it, or Space or Enter, opens and closes it. Expanded, the row lists Name, Start type, Status, Account, Path and arguments, Triggers and, once the service has stopped, Last result, the labels ReportMate's Windows System module uses. A value that cannot be read is left out rather than shown empty. --- src/App/Converters/ServiceStateBrush.cs | 15 +++ src/App/Views/PrefsPage.xaml | 76 ++++++------ src/Infrastructure/Gui/InstalledServices.cs | 109 ++++++++++++++---- .../Infrastructure/InstalledServicesTests.cs | 68 ++++++++--- 4 files changed, 196 insertions(+), 72 deletions(-) create mode 100644 src/App/Converters/ServiceStateBrush.cs diff --git a/src/App/Converters/ServiceStateBrush.cs b/src/App/Converters/ServiceStateBrush.cs new file mode 100644 index 0000000..cbf3d61 --- /dev/null +++ b/src/App/Converters/ServiceStateBrush.cs @@ -0,0 +1,15 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; + +namespace StartSet.App.Converters; + +/// The dot beside a Services row's state, from theme brushes only. +public static class ServiceStateBrush +{ + public static Brush For(string state) => (Brush)Application.Current.Resources[state switch + { + "Running" => "SystemFillColorSuccessBrush", + "Ready" => "SystemFillColorAttentionBrush", + _ => "SystemFillColorNeutralBrush" + }]; +} diff --git a/src/App/Views/PrefsPage.xaml b/src/App/Views/PrefsPage.xaml index 27a8c24..6e31936 100644 --- a/src/App/Views/PrefsPage.xaml +++ b/src/App/Views/PrefsPage.xaml @@ -3,6 +3,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:gui="using:StartSet.Infrastructure.Gui" + xmlns:cvt="using:StartSet.App.Converters" NavigationCacheMode="Required"> @@ -98,44 +99,49 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Infrastructure/Gui/InstalledServices.cs b/src/Infrastructure/Gui/InstalledServices.cs index cedcc2b..4c68dfd 100644 --- a/src/Infrastructure/Gui/InstalledServices.cs +++ b/src/Infrastructure/Gui/InstalledServices.cs @@ -5,6 +5,9 @@ namespace StartSet.Infrastructure.Gui; /// A Windows service or scheduled task the StartSet installer creates. public sealed record FootprintEntry(string Name, string FriendlyName, string Kind, string Description); +/// One label and value in an expanded Services row. +public sealed record FootprintDetail(string Label, string Value); + /// One row of the Prefs tab's Services card. public sealed record FootprintStatus( string FriendlyName, @@ -12,11 +15,8 @@ public sealed record FootprintStatus( string Kind, string Description, string State, - string LastResult, - string Trigger) -{ - public string NameAndKind => $"{Name} · {Kind}"; -} + string Summary, + IReadOnlyList Details); /// /// The services and scheduled tasks StartSet installs, and their state for the Prefs tab's @@ -26,12 +26,24 @@ public sealed record FootprintStatus( /// The list is the installer's footprint, kept by hand: the package's postinstall.ps1 and the /// MSI both create the one service, StartSet, set to start automatically (not delayed). Neither /// creates a scheduled task. State comes from WMI's Win32_Service, which a standard user may -/// read, so the card needs no elevation; a query that fails shows "Unknown". +/// read, so the card needs no elevation; a query that fails shows "Unknown". The detail labels +/// follow the fields ReportMate's Windows System module reports for a service. /// public static class InstalledServices { public const string ServiceKind = "Service"; + public const string NotInstalled = "Not installed"; + public const string Unknown = "Unknown"; + + public const string NameLabel = "Name"; + public const string StartTypeLabel = "Start type"; + public const string StatusLabel = "Status"; + public const string AccountLabel = "Account"; + public const string PathLabel = "Path and arguments"; + public const string TriggersLabel = "Triggers"; + public const string LastResultLabel = "Last result"; + public static IReadOnlyList Footprint { get; } = [ new("StartSet", "StartSet Service", ServiceKind, @@ -46,40 +58,89 @@ private static FootprintStatus QueryService(FootprintEntry entry) try { using var searcher = new ManagementObjectSearcher( - $"SELECT State, StartMode, DelayedAutoStart, ExitCode FROM Win32_Service WHERE Name = '{entry.Name}'"); + $"SELECT State, StartMode, DelayedAutoStart, ExitCode, StartName, PathName FROM Win32_Service WHERE Name = '{entry.Name}'"); using var results = searcher.Get(); var service = results.Cast().FirstOrDefault(); if (service is null) - return Row(entry, NotInstalled, "—", "—"); + return Row(entry, NotInstalled, ServiceSummary(null, false, null), [new(StatusLabel, NotInstalled)]); var state = service["State"] as string; var startMode = service["StartMode"] as string; var delayed = service["DelayedAutoStart"] is true; - var exitCode = service["ExitCode"] is uint code ? (int?)code : null; - return Row(entry, ServiceStateText(state, startMode), ServiceLastResultText(state, exitCode), StartModeText(startMode, delayed)); + var account = service["StartName"] as string; + var status = ServiceStateText(state, startMode); + var details = ServiceDetails( + entry.Name, + status, + startMode, + delayed, + state, + exitCode: service["ExitCode"] is uint code ? (int)code : null, + account, + path: service["PathName"] as string); + return Row(entry, status, ServiceSummary(startMode, delayed, account), details); } catch (Exception) { - return Row(entry, Unknown, Unknown, Unknown); + return Row(entry, Unknown, ServiceSummary(null, false, null), [new(StatusLabel, Unknown)]); } } - private static FootprintStatus Row(FootprintEntry entry, string state, string lastResult, string trigger) => - new(entry.FriendlyName, entry.Name, entry.Kind, entry.Description, state, lastResult, trigger); + private static FootprintStatus Row(FootprintEntry entry, string state, string summary, IReadOnlyList details) => + new(entry.FriendlyName, entry.Name, entry.Kind, entry.Description, state, summary, details); // ── Plain words ───────────────────────────────────────────── - public const string NotInstalled = "Not installed"; - public const string Unknown = "Unknown"; + /// + /// The collapsed row's one summary line, e.g. "Service · Automatic · LocalSystem". A part + /// that cannot be read is left out. + /// + public static string ServiceSummary(string? startMode, bool delayed, string? account) => + string.Join(" · ", new[] { ServiceKind, StartTypeText(startMode, delayed), account } + .Where(part => !string.IsNullOrWhiteSpace(part))); - /// Win32_Service.StartMode ("Auto", "Manual", "Disabled", "Boot", "System") in words. - public static string StartModeText(string? startMode, bool delayed) => startMode switch + /// + /// A service's expanded rows, in the card's fixed order. A row whose value is missing is + /// left out rather than shown empty. + /// + public static IReadOnlyList ServiceDetails( + string name, string status, string? startMode, bool delayed, string? state, int? exitCode, string? account, string? path) { - "Auto" when delayed => "Starts automatically (delayed) at boot", - "Auto" or "Boot" or "System" => "Starts automatically at boot", - "Manual" => "Starts manually", + var rows = new List(); + void Add(string label, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + rows.Add(new(label, value)); + } + + Add(NameLabel, name); + Add(StartTypeLabel, StartTypeText(startMode, delayed)); + Add(StatusLabel, status); + Add(AccountLabel, account); + Add(PathLabel, path); + Add(TriggersLabel, ServiceTriggerText(startMode, delayed)); + Add(LastResultLabel, ServiceLastResultText(state, exitCode)); + return rows; + } + + /// Win32_Service.StartMode ("Auto", "Manual", "Disabled", "Boot", "System") as a start type. + public static string? StartTypeText(string? startMode, bool delayed) => startMode switch + { + "Auto" when delayed => "Automatic (delayed)", + "Auto" or "Boot" or "System" => "Automatic", + "Manual" => "Manual", "Disabled" => "Disabled", - _ => Unknown + _ => null + }; + + /// What starts the service, in words, from its start type. + public static string? ServiceTriggerText(string? startMode, bool delayed) => startMode switch + { + "Auto" when delayed => "Shortly after boot (delayed start)", + "Auto" or "Boot" or "System" => "At boot", + "Manual" => "When started by a user or another program", + "Disabled" => "None: the service is disabled", + _ => null }; /// @@ -102,8 +163,8 @@ public static string ServiceStateText(string? state, string? startMode) /// /// The service's Win32 exit code, which only means something once it has stopped. While it - /// runs there is no last result to report. + /// runs there is no last result, and the row is left out. /// - public static string ServiceLastResultText(string? state, int? exitCode) => - state == "Stopped" && exitCode is { } code ? $"Exit code {code}" : "—"; + public static string? ServiceLastResultText(string? state, int? exitCode) => + state == "Stopped" && exitCode is { } code ? $"Exit code {code}" : null; } diff --git a/tests/StartSet.Tests/Infrastructure/InstalledServicesTests.cs b/tests/StartSet.Tests/Infrastructure/InstalledServicesTests.cs index 8b207d6..3cd81fc 100644 --- a/tests/StartSet.Tests/Infrastructure/InstalledServicesTests.cs +++ b/tests/StartSet.Tests/Infrastructure/InstalledServicesTests.cs @@ -14,17 +14,28 @@ public void Footprint_IsTheOneServiceTheInstallerCreates() } [Theory] - [InlineData("Auto", false, "Starts automatically at boot")] - [InlineData("Auto", true, "Starts automatically (delayed) at boot")] - [InlineData("Boot", false, "Starts automatically at boot")] - [InlineData("Manual", false, "Starts manually")] - [InlineData("Manual", true, "Starts manually")] + [InlineData("Auto", false, "Automatic")] + [InlineData("Auto", true, "Automatic (delayed)")] + [InlineData("Boot", false, "Automatic")] + [InlineData("Manual", false, "Manual")] + [InlineData("Manual", true, "Manual")] [InlineData("Disabled", false, "Disabled")] - [InlineData(null, false, "Unknown")] - [InlineData("Something", false, "Unknown")] - public void StartMode_InWords(string? startMode, bool delayed, string expected) + [InlineData(null, false, null)] + [InlineData("Something", false, null)] + public void StartType_InWords(string? startMode, bool delayed, string? expected) { - InstalledServices.StartModeText(startMode, delayed).Should().Be(expected); + InstalledServices.StartTypeText(startMode, delayed).Should().Be(expected); + } + + [Theory] + [InlineData("Auto", false, "At boot")] + [InlineData("Auto", true, "Shortly after boot (delayed start)")] + [InlineData("Manual", false, "When started by a user or another program")] + [InlineData("Disabled", false, "None: the service is disabled")] + [InlineData(null, false, null)] + public void Triggers_FollowTheStartType(string? startMode, bool delayed, string? expected) + { + InstalledServices.ServiceTriggerText(startMode, delayed).Should().Be(expected); } [Theory] @@ -43,19 +54,50 @@ public void State_InWords(string? state, string? startMode, string expected) [Theory] [InlineData("Stopped", 0, "Exit code 0")] [InlineData("Stopped", 1067, "Exit code 1067")] - [InlineData("Running", 0, "—")] - [InlineData("Stopped", null, "—")] - public void LastResult_IsTheExitCode_OnlyOnceStopped(string? state, int? exitCode, string expected) + [InlineData("Running", 0, null)] + [InlineData("Stopped", null, null)] + public void LastResult_IsTheExitCode_OnlyOnceStopped(string? state, int? exitCode, string? expected) { InstalledServices.ServiceLastResultText(state, exitCode).Should().Be(expected); } + [Fact] + public void RunningService_ShowsItsRows_InTheFixedOrder_WithoutALastResult() + { + var rows = InstalledServices.ServiceDetails("StartSet", "Running", "Auto", delayed: false, "Running", exitCode: 0, + account: "LocalSystem", path: @"""C:\Program Files\StartSet\StartSetService.exe"""); + + rows.Select(r => r.Label).Should().Equal("Name", "Start type", "Status", "Account", "Path and arguments", "Triggers"); + rows.Select(r => r.Value).Should().Equal("StartSet", "Automatic", "Running", "LocalSystem", + @"""C:\Program Files\StartSet\StartSetService.exe""", "At boot"); + } + + [Fact] + public void StoppedService_AddsItsExitCode_AndMissingValues_AreLeftOut() + { + var rows = InstalledServices.ServiceDetails("StartSet", "Stopped", "Manual", delayed: false, "Stopped", exitCode: 1067, + account: null, path: " "); + + rows.Select(r => r.Label).Should().Equal("Name", "Start type", "Status", "Triggers", "Last result"); + rows.Last().Value.Should().Be("Exit code 1067"); + } + + [Theory] + [InlineData("Auto", false, "LocalSystem", "Service · Automatic · LocalSystem")] + [InlineData("Auto", true, @"NT AUTHORITY\LocalService", @"Service · Automatic (delayed) · NT AUTHORITY\LocalService")] + [InlineData(null, false, null, "Service")] + public void Summary_IsKind_StartType_AndAccount(string? startMode, bool delayed, string? account, string expected) + { + InstalledServices.ServiceSummary(startMode, delayed, account).Should().Be(expected); + } + [Fact] public void Query_ReturnsARow_ForEveryEntry_EvenWhenNotInstalled() { var rows = InstalledServices.Query(); rows.Select(r => r.Name).Should().Equal(InstalledServices.Footprint.Select(e => e.Name)); - rows.Should().OnlyContain(r => !string.IsNullOrEmpty(r.State) && !string.IsNullOrEmpty(r.Trigger)); + rows.Should().OnlyContain(r => !string.IsNullOrEmpty(r.State) && r.Details.Any(d => d.Label == "Status")); + rows[0].Summary.Should().StartWith("Service"); } }