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");
+ }
+}