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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ taskbarutil add "Microsoft.Windows.Explorer" --app-id
taskbarutil add "Chrome" --dry-run
```

### Apply Options

By default `apply` locks the layout (`LockedStartLayout = 1`), so users cannot rearrange, pin or unpin anything afterwards. Pass `--seed` to set the same pins as a starting point instead: it does everything `apply` does, but writes `LockedStartLayout = 0`, leaving the taskbar editable. Use the default to enforce a layout, and `--seed` to hand users a sensible starting layout they are free to change.

```powershell
taskbarutil apply --seed
```

`--allhomes` applies to every signed-in user profile and the Default profile, and combines with `--seed`. `--no-restart` skips the explorer restart, so the layout takes effect at the next sign-in.

```powershell
taskbarutil apply --allhomes --seed
```

## Requirements

- Windows 11
Expand All @@ -107,7 +121,7 @@ TaskbarUtil stores its config at `%LocalAppData%\TaskbarUtil\LayoutModification.
| Key | Value | Purpose |
|-----|-------|---------|
| `HKCU\...\Explorer\StartLayoutFile` | Path to XML | Points to the layout config |
| `HKCU\...\Explorer\LockedStartLayout` | `1` | Activates the layout policy |
| `HKCU\...\Explorer\LockedStartLayout` | `1`, or `0` with `--seed` | `1` locks the layout; `0` seeds it and leaves it editable |

The XML must include `Version="1"` on the `LayoutModificationTemplate` root element -- without it, Windows silently ignores the entire file.

Expand Down
22 changes: 14 additions & 8 deletions src/Commands/ApplyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ public static Command Create(Option<bool> verboseOption, Option<bool> dryRunOpti
{
var noRestartOption = new Option<bool>("--no-restart", "Do not restart explorer after applying");
var allHomesOption = new Option<bool>("--allhomes", "Apply to all user profiles on this machine");
var seedOption = new Option<bool>("--seed",
"Seed the layout without locking it (LockedStartLayout = 0), so users can still rearrange, pin and unpin");

var command = new Command("apply", "Apply the layout config via local policy")
{
noRestartOption,
allHomesOption
allHomesOption,
seedOption
};

command.SetHandler((noRestart, allHomes, verbose, dryRun) =>
command.SetHandler((noRestart, allHomes, seed, verbose, dryRun) =>
{
var configPath = EnvironmentInfo.ConfigFilePath;

Expand Down Expand Up @@ -48,23 +51,24 @@ public static Command Create(Option<bool> verboseOption, Option<bool> dryRunOpti
Console.WriteLine($" Pins: {layout.Pins.Count}");
foreach (var pin in layout.Pins)
Console.WriteLine($" - {pin.DisplayName} ({pin.Type})");
Console.WriteLine($" Locked: {!seed}");
Console.WriteLine($" Restart explorer: {!noRestart}");
Log.Debug($"apply: dry run, would apply {layout.Pins.Count} pin(s) [{pinSummary}] from {configPath} (allhomes={allHomes}, restart={!noRestart})");
Log.Debug($"apply: dry run, would apply {layout.Pins.Count} pin(s) [{pinSummary}] from {configPath} (allhomes={allHomes}, seed={seed}, restart={!noRestart})");
return;
}

Log.Info($"apply: applying {layout.Pins.Count} pin(s) [{pinSummary}] from {configPath} (allhomes={allHomes}, restart={!noRestart})");
Log.Info($"apply: applying {layout.Pins.Count} pin(s) [{pinSummary}] from {configPath} (allhomes={allHomes}, seed={seed}, restart={!noRestart})");

// Step 1: Deploy layout XML via policy registry keys
if (allHomes)
{
var count = PolicyManager.ApplyAllHomes(configPath, verbose);
var count = PolicyManager.ApplyAllHomes(configPath, verbose, seed);
Console.WriteLine($"Policy set for {count} user profile(s).");
Log.Info($"apply: policy set for {count} user profile(s)");
}
else
{
var policyResult = PolicyManager.Apply(configPath, verbose);
var policyResult = PolicyManager.Apply(configPath, verbose, seed);
Console.WriteLine($"Policy set via {policyResult.Method}.");
if (policyResult.Message != null)
Console.WriteLine($" {policyResult.Message}");
Expand All @@ -88,10 +92,12 @@ public static Command Create(Option<bool> verboseOption, Option<bool> dryRunOpti
Log.Info("apply: explorer not restarted; layout takes effect at next sign-in");
}

Console.WriteLine($"Taskbar layout applied with {layout.Pins.Count} pin(s).");
Console.WriteLine(seed
? $"Taskbar layout seeded with {layout.Pins.Count} pin(s); users can still change it."
: $"Taskbar layout applied with {layout.Pins.Count} pin(s).");
Log.Info($"apply: completed with {layout.Pins.Count} pin(s)");

}, noRestartOption, allHomesOption, verboseOption, dryRunOption);
}, noRestartOption, allHomesOption, seedOption, verboseOption, dryRunOption);

return command;
}
Expand Down
13 changes: 9 additions & 4 deletions src/Commands/ListCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,20 @@ public static Command Create()
}
}

// Show policy-applied layout if active
if (PolicyManager.IsApplied())
// Show policy-applied layout if active. A seeded layout (apply --seed)
// is only a starting point, so the user's own pins may differ from it.
var policyApplied = PolicyManager.IsApplied();
var policySeeded = policyApplied && PolicyManager.IsSeeded();
if (policyApplied)
{
var layoutPath = PolicyManager.GetAppliedLayoutPath()
?? EnvironmentInfo.ConfigFilePath;
var layout = LayoutXmlParser.TryLoadFromFile(layoutPath);

Console.WriteLine();
Console.WriteLine("# Policy layout (active)");
Console.WriteLine(policySeeded
? "# Policy layout (seeded, editable)"
: "# Policy layout (active)");
Console.WriteLine();

if (layout != null && layout.Pins.Count > 0)
Expand All @@ -55,7 +60,7 @@ public static Command Create()

Console.WriteLine();
var total = items.Count;
if (PolicyManager.IsApplied())
if (policyApplied && !policySeeded)
{
var layout = LayoutXmlParser.TryLoadFromFile(
PolicyManager.GetAppliedLayoutPath() ?? EnvironmentInfo.ConfigFilePath);
Expand Down
47 changes: 41 additions & 6 deletions src/Core/PolicyManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,27 @@ public static class PolicyManager
static readonly string ShellLayoutPath =
Path.Combine(ShellDirectory, "LayoutModification.xml");

public static ApplyResult Apply(string xmlFilePath, bool verbose = false)
/// <summary>
/// Apply the layout to the current user. With <paramref name="seed"/> the
/// layout is written with LockedStartLayout = 0, so the pins are a starting
/// point the user can still rearrange, pin and unpin; otherwise the layout
/// is locked (LockedStartLayout = 1).
/// </summary>
public static ApplyResult Apply(string xmlFilePath, bool verbose = false, bool seed = false)
{
var locked = seed ? 0 : 1;

// Try registry policy first
try
{
using var key = Registry.CurrentUser.CreateSubKey(PolicyKeyPath);
key.SetValue("StartLayoutFile", xmlFilePath, RegistryValueKind.ExpandString);
key.SetValue("LockedStartLayout", 1, RegistryValueKind.DWord);
key.SetValue("LockedStartLayout", locked, RegistryValueKind.DWord);

if (verbose)
{
Console.Error.WriteLine($" [policy] Set HKCU\\{PolicyKeyPath}\\StartLayoutFile = {xmlFilePath}");
Console.Error.WriteLine($" [policy] Set HKCU\\{PolicyKeyPath}\\LockedStartLayout = 1");
Console.Error.WriteLine($" [policy] Set HKCU\\{PolicyKeyPath}\\LockedStartLayout = {locked}");
}

return new ApplyResult(ApplyMethod.RegistryPolicy, PolicyKeyPath, true);
Expand Down Expand Up @@ -133,6 +141,11 @@ public static bool IsApplied()
var val = key.GetValue("LockedStartLayout");
if (val is int i && i == 1)
return true;

// A seeded layout (apply --seed) leaves LockedStartLayout = 0
// but still points StartLayoutFile at the config.
if (key.GetValue("StartLayoutFile") is string path && path.Length > 0)
return true;
}
}
catch { }
Expand All @@ -141,6 +154,25 @@ public static bool IsApplied()
return File.Exists(ShellLayoutPath);
}

/// <summary>
/// True when the registry policy points StartLayoutFile at a layout without
/// locking it (apply --seed): the pins were a starting point, not enforced.
/// </summary>
public static bool IsSeeded()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(PolicyKeyPath);
if (key == null) return false;
var locked = key.GetValue("LockedStartLayout") is int i && i == 1;
return !locked && key.GetValue("StartLayoutFile") is string path && path.Length > 0;
}
catch
{
return false;
}
}

public static string? GetAppliedLayoutPath()
{
try
Expand All @@ -158,10 +190,13 @@ public static bool IsApplied()
/// Apply the taskbar layout to all user profiles on the machine.
/// Copies XML to a shared ProgramData location, then sets policy keys
/// in each user's HKU hive and clears their caches.
/// Requires elevation (admin/SYSTEM).
/// Requires elevation (admin/SYSTEM). With <paramref name="seed"/> each
/// profile gets LockedStartLayout = 0 instead of 1, leaving the pins editable.
/// </summary>
public static int ApplyAllHomes(string xmlFilePath, bool verbose = false)
public static int ApplyAllHomes(string xmlFilePath, bool verbose = false, bool seed = false)
{
var locked = seed ? 0 : 1;

// Copy XML to shared location so all users reference the same file
Directory.CreateDirectory(SharedXmlDir);
File.Copy(xmlFilePath, SharedXmlPath, overwrite: true);
Expand All @@ -186,7 +221,7 @@ public static int ApplyAllHomes(string xmlFilePath, bool verbose = false)
{
using var hku = Registry.Users.CreateSubKey($@"{profile.Sid}\{PolicyKeyPath}");
hku.SetValue("StartLayoutFile", SharedXmlPath, RegistryValueKind.ExpandString);
hku.SetValue("LockedStartLayout", 1, RegistryValueKind.DWord);
hku.SetValue("LockedStartLayout", locked, RegistryValueKind.DWord);

// Clear start2.bin cache
var start2 = Path.Combine(profile.ProfilePath,
Expand Down