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/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 e5e5597..862b824 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 { } @@ -141,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 @@ -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 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 +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,