From 03b8f11e61dc522df06af4fd3a594ad7fb862035 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Wed, 7 Oct 2026 21:39:16 -0700 Subject: [PATCH 1/2] Add apply --seed to set an editable taskbar layout without locking it apply always wrote LockedStartLayout = 1, so a package could enforce a layout but never seed one that users can change afterwards. --seed does everything apply does, for the current user and with --allhomes, but writes LockedStartLayout = 0. list still reports a seeded layout as active. The default stays locked. --- README.md | 16 +++++++++++++++- src/Commands/ApplyCommand.cs | 22 ++++++++++++++-------- src/Core/PolicyManager.cs | 28 ++++++++++++++++++++++------ 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 23c3146..5f382df 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/src/Commands/ApplyCommand.cs b/src/Commands/ApplyCommand.cs index de5e1d3..bbeeee4 100644 --- a/src/Commands/ApplyCommand.cs +++ b/src/Commands/ApplyCommand.cs @@ -9,14 +9,17 @@ public static Command Create(Option verboseOption, Option dryRunOpti { var noRestartOption = new Option("--no-restart", "Do not restart explorer after applying"); var allHomesOption = new Option("--allhomes", "Apply to all user profiles on this machine"); + var seedOption = new Option("--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; @@ -48,23 +51,24 @@ public static Command Create(Option verboseOption, Option 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}"); @@ -88,10 +92,12 @@ public static Command Create(Option verboseOption, Option 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; } diff --git a/src/Core/PolicyManager.cs b/src/Core/PolicyManager.cs index e5e5597..2698165 100644 --- a/src/Core/PolicyManager.cs +++ b/src/Core/PolicyManager.cs @@ -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) + /// + /// Apply the layout to the current user. With 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). + /// + 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); @@ -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 { } @@ -158,10 +171,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 each + /// profile gets LockedStartLayout = 0 instead of 1, leaving the pins editable. /// - 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); @@ -186,7 +202,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, From 3d0cdb19f3c4d6b07b73ff997fd02f2a14653e8f Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Wed, 7 Oct 2026 22:51:40 -0700 Subject: [PATCH 2/2] Show a seeded layout as editable in list rather than as policy-enforced #4265 After apply --seed users can change their pins, so list now labels the policy layout as seeded and counts the pins actually on the taskbar instead of reporting the seeded pins as configured via policy. Locked and direct-copy layouts print as before. --- src/Commands/ListCommand.cs | 13 +++++++++---- src/Core/PolicyManager.cs | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Commands/ListCommand.cs b/src/Commands/ListCommand.cs index 0ec3953..2bc6563 100644 --- a/src/Commands/ListCommand.cs +++ b/src/Commands/ListCommand.cs @@ -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) @@ -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); diff --git a/src/Core/PolicyManager.cs b/src/Core/PolicyManager.cs index 2698165..862b824 100644 --- a/src/Core/PolicyManager.cs +++ b/src/Core/PolicyManager.cs @@ -154,6 +154,25 @@ public static bool IsApplied() return File.Exists(ShellLayoutPath); } + /// + /// True when the registry policy points StartLayoutFile at a layout without + /// locking it (apply --seed): the pins were a starting point, not enforced. + /// + 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