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/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/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 be35a17..53302ee 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", @@ -45,38 +50,39 @@ 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); + public int ManagedCount => Items.Count(i => i.IsManaged); - partial void OnUnlockErrorChanged(string value) => OnPropertyChanged(nameof(HasUnlockError)); + /// True when policy sets at least one value, which shows the policy line at the top. + public bool HasPolicySettings => ManagedCount > 0; - // ── Save Status ────────────────────────────────────────────── + // ── Services ───────────────────────────────────────────────── - [ObservableProperty] private string _saveMessage = ""; - [ObservableProperty] private bool _saveFailed; + /// The installer's services and scheduled tasks, for the read-only Services card. + public ObservableCollection Services { get; } = []; - public int ManagedCount => Items.Count(i => i.IsManaged); + /// 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 { @@ -89,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(); @@ -105,13 +114,15 @@ 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(); OnPropertyChanged(nameof(Items)); OnPropertyChanged(nameof(ManagedCount)); + OnPropertyChanged(nameof(HasPolicySettings)); + Reloaded?.Invoke(); } private static SettingKind KindOf(PreferenceSetting setting) @@ -124,79 +135,151 @@ 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 and sent the edits over a pipe, 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."; + + try + { + // 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."; + + // 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)}."; } 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}"; } } + + /// 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 ee957a4..6e31936 100644 --- a/src/App/Views/PrefsPage.xaml +++ b/src/App/Views/PrefsPage.xaml @@ -2,6 +2,8 @@ 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" + xmlns:cvt="using:StartSet.App.Converters" NavigationCacheMode="Required"> @@ -34,52 +36,118 @@ HorizontalAlignment="Center" /> - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - diff --git a/src/App/Views/PrefsPage.xaml.cs b/src/App/Views/PrefsPage.xaml.cs index 38a9d76..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,21 +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 UnlockButton_Click(object sender, RoutedEventArgs e) + private void RefreshServices_Click(object sender, RoutedEventArgs e) => _ = ViewModel.RefreshServicesAsync(); + + private async void Save_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; + await ViewModel.SaveAsync(); + } + + private void Discard_Click(object sender, RoutedEventArgs e) + { + SaveStatus.Visibility = Visibility.Collapsed; + ViewModel.Discard(); } // ── Card construction ─────────────────────────────────────── @@ -126,35 +139,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) { @@ -163,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: @@ -175,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: @@ -200,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/InstalledServices.cs b/src/Infrastructure/Gui/InstalledServices.cs new file mode 100644 index 0000000..4c68dfd --- /dev/null +++ b/src/Infrastructure/Gui/InstalledServices.cs @@ -0,0 +1,170 @@ +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 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, + string Name, + string Kind, + string Description, + string State, + string Summary, + IReadOnlyList Details); + +/// +/// 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". 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, + "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, 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, 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 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, ServiceSummary(null, false, null), [new(StatusLabel, Unknown)]); + } + } + + 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 ───────────────────────────────────────────── + + /// + /// 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))); + + /// + /// 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) + { + 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", + _ => 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 + }; + + /// + /// 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, and the row is left out. + /// + public static string? ServiceLastResultText(string? state, int? exitCode) => + state == "Stopped" && exitCode is { } code ? $"Exit code {code}" : null; +} diff --git a/src/Infrastructure/Gui/PrefsElevation.cs b/src/Infrastructure/Gui/PrefsElevation.cs index d4b094d..739eb6d 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 it reads + /// from the pipe to machine settings, with no window. + /// + public static ProcessStartInfo BuildElevatedApply(string exePath, string pipeName) { ArgumentException.ThrowIfNullOrWhiteSpace(exePath); + ArgumentException.ThrowIfNullOrWhiteSpace(pipeName); return new ProcessStartInfo { FileName = exePath, - Arguments = PrefsArgument, + 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 new file mode 100644 index 0000000..96e8308 --- /dev/null +++ b/src/Infrastructure/Gui/SettingsPayload.cs @@ -0,0 +1,289 @@ +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; + +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 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 +{ + /// 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}") + }; + + // ── 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) + { + 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 ───────────────────────────────────────────────── + + /// + /// 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 from the pipe named after + /// , writes it with the real policy and machine settings, and + /// returns the process exit code. and + /// stand in for the pipe and registry in tests. + /// + public static int RunHeadless( + IReadOnlyList args, + Func? serviceFactory = null, + Func? receive = 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; + var pipeName = args[index + 1]; + values = Deserialize(receive?.Invoke(pipeName) ?? ReceiveFromPipe(pipeName, TimeSpan.FromSeconds(10))); + } + 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..a0946ec 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", "startset-settings-abc"); start.Verb.Should().Be("runas"); start.UseShellExecute.Should().BeTrue(); - PrefsElevation.OpensOnPrefs([start.Arguments]).Should().BeTrue(); + 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/InstalledServicesTests.cs b/tests/StartSet.Tests/Infrastructure/InstalledServicesTests.cs new file mode 100644 index 0000000..3cd81fc --- /dev/null +++ b/tests/StartSet.Tests/Infrastructure/InstalledServicesTests.cs @@ -0,0 +1,103 @@ +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, "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, null)] + [InlineData("Something", false, null)] + public void StartType_InWords(string? startMode, bool delayed, string? 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] + [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, 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) && r.Details.Any(d => d.Label == "Status")); + rows[0].Summary.Should().StartWith("Service"); + } +} 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..297417b --- /dev/null +++ b/tests/StartSet.Tests/Infrastructure/SettingsPayloadTests.cs @@ -0,0 +1,153 @@ +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 async Task RunHeadless_ReadsThePipe_AndRereadsPolicy() + { + var policy = new InMemorySettingsStore().With("Verbose", 0); + var machine = new InMemorySettingsStore(); + 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 = 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); + machine.Values.Should().NotContainKey("Verbose", "policy sets it"); + } + + [Fact] + public void RunHeadless_FailsOnAMissingOrBadPayload() + { + SettingsPayload.RunHeadless([SettingsPayload.ApplyArgument], () => TestSettings.Service()).Should().Be(SettingsPayload.ExitBadPayload); + 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); + } + + [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] + 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"); + } +}