Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:

Expand Down
15 changes: 15 additions & 0 deletions src/App/Converters/ServiceStateBrush.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;

namespace StartSet.App.Converters;

/// <summary>The dot beside a Services row's state, from theme brushes only.</summary>
public static class ServiceStateBrush
{
public static Brush For(string state) => (Brush)Application.Current.Resources[state switch
{
"Running" => "SystemFillColorSuccessBrush",
"Ready" => "SystemFillColorAttentionBrush",
_ => "SystemFillColorNeutralBrush"
}];
}
5 changes: 2 additions & 3 deletions src/App/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavigationViewItem>()
.FirstOrDefault(i => (string?)i.Tag == tab) ?? NavView.MenuItems[0];
}
Expand Down
28 changes: 28 additions & 0 deletions src/App/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using StartSet.Infrastructure.Gui;

namespace StartSet.App;

/// <summary>
/// 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.
/// </summary>
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;
}
}
2 changes: 2 additions & 0 deletions src/App/StartSet.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
generates resources.pri with makepri.exe from the Windows SDK instead. -->
<EnableCoreMrtTooling>false</EnableCoreMrtTooling>
<NoWarn>$(NoWarn);MVVMTK0045</NoWarn>
<!-- Program.cs is the entry point, so a headless settings write never starts WinUI. -->
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
</PropertyGroup>

<ItemGroup>
Expand Down
217 changes: 150 additions & 67 deletions src/App/ViewModels/PrefsViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using StartSet.Infrastructure.Configuration;
using StartSet.Infrastructure.Gui;
Expand All @@ -23,10 +24,14 @@ public sealed class SettingItem
public string Name => Setting.Name;
public bool IsManaged => Source == SettingSource.Policy;

/// <summary>Where the shown value comes from, as the caption under the control says it.</summary>
/// <summary>
/// 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.
/// </summary>
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",
Expand All @@ -45,38 +50,39 @@ public sealed class SettingItem

/// <summary>
/// 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.
/// </summary>
public partial class PrefsViewModel : ObservableObject
{
public static readonly string[] LogLevels = ["", "Debug", "Information", "Warning", "Error"];

private readonly Dictionary<string, System.Threading.Timer> _pendingSaves = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, object> _pending = new(StringComparer.OrdinalIgnoreCase);
private PreferencesService _service = new();

public List<SettingItem> Items { get; private set; } = [];

// ── Elevation State ─────────────────────────────────────────

/// <summary>True when this process can write HKLM (elevated administrator token).</summary>
/// <summary>True when this process can write HKLM itself, so Save needs no UAC prompt.</summary>
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));
/// <summary>True when policy sets at least one value, which shows the policy line at the top.</summary>
public bool HasPolicySettings => ManagedCount > 0;

// ── Save Status ──────────────────────────────────────────────
// ── Services ─────────────────────────────────────────────────

[ObservableProperty] private string _saveMessage = "";
[ObservableProperty] private bool _saveFailed;
/// <summary>The installer's services and scheduled tasks, for the read-only Services card.</summary>
public ObservableCollection<FootprintStatus> Services { get; } = [];

public int ManagedCount => Items.Count(i => i.IsManaged);
/// <summary>Queries the services off the UI thread and replaces the card's rows.</summary>
public async Task RefreshServicesAsync()
{
var rows = await Task.Run(InstalledServices.Query);
Services.Clear();
foreach (var row in rows)
Services.Add(row);
}

public string VersionDisplay
{
Expand All @@ -89,6 +95,9 @@ public string VersionDisplay

// ── Load ─────────────────────────────────────────────────────

/// <summary>Raised after the settings are read again, so the page rebuilds its cards.</summary>
public event Action? Reloaded;

public void Load()
{
_service = new PreferencesService();
Expand All @@ -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)
Expand All @@ -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";

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>Raised on the UI thread's behalf; the page marshals it.</summary>
public event Action<string, bool>? 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<string> a && normalized is List<string> b
? a.SequenceEqual(b)
: Equals(saved, normalized);
}

private static object Normalize(SettingItem item, object? value) => item.Kind switch
/// <summary>Drops every unsaved edit and shows the saved values again.</summary>
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 ─────────────────────────────────────────────────────

/// <summary>
/// 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.
/// </summary>
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<string, object>(_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<string?> SaveDirectAsync(Dictionary<string, object> 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<string?> SaveElevatedAsync(Dictionary<string, object> 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}";
}
}

/// <summary>Raised with a status line for the page to show.</summary>
public event Action<string, bool>? SaveReported;

private void Report(string message, bool failed) => SaveReported?.Invoke(message, failed);
}
Loading
Loading