From e054b09771c12ec7658ff8f9cf54bbced39d958a Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 13:33:14 +0800 Subject: [PATCH] fix(installer): group WSL distributions under one context submenu --- .../2026-09-23-wsl-context-menu-cascade.md | 59 +++++++ scripts/installer-migration.iss | 146 ++++++++++++------ scripts/installer.iss | 4 +- scripts/tests/installer-migration-fixture.iss | 69 ++++++++- 4 files changed, 227 insertions(+), 51 deletions(-) create mode 100644 architecture/notes/packaging/2026-09-23-wsl-context-menu-cascade.md diff --git a/architecture/notes/packaging/2026-09-23-wsl-context-menu-cascade.md b/architecture/notes/packaging/2026-09-23-wsl-context-menu-cascade.md new file mode 100644 index 0000000..69343a5 --- /dev/null +++ b/architecture/notes/packaging/2026-09-23-wsl-context-menu-cascade.md @@ -0,0 +1,59 @@ +# Group WSL launch entries under one Explorer submenu + +## Status + +Implemented; pending review. + +## Context + +One top-level context item per WSL distribution crowds Explorer menus. The user +requested one ordinary Pebrel entry and one expandable entry for distribution +choices, for both selected directories and directory backgrounds. + +## Evidence + +The previous installer registered `PebrelWsl` directly under each Explorer +shell root. Those entries remain registered after an upgrade unless explicitly +migrated. WSL distribution enumeration already excludes Docker plumbing entries. + +## Decision + +Keep the ordinary entry. Register a static `PebrelWslMenu` cascade with an empty +`SubCommands` value and its distribution verbs under `shell`. Record its executable +owner and validate child commands before replacing or removing the subtree. +Migrate only flat entries whose command invokes the exact owned executable. + +Preserve `%1` for selected directories, `%V` for backgrounds, and the existing +`--shell` before `--working-directory` order. No distributions means no empty +submenu. Conflicting/edited subtrees and other installations remain untouched. + +## Rejected alternatives + +- Removing distribution choices reduces existing launch functionality. +- Deleting every key with the product prefix can remove another installation or + user customization. +- Installing a new Explorer extension adds deployment and lifetime requirements + unnecessary for this static cascade. + +## Consequences + +Registration still reflects distributions present at installation time. Existing +menus migrate when the new installer runs; changing this source does not modify +the user's live registry. Conflicts fail visibly rather than overwrite unknown keys. + +## Validation + +The real Inno fixture uses an isolated HKCU test subtree and passes 117 migration +checks in total. New coverage includes both roots, zero/one/three distributions, +Unicode/space-containing names, repeat registration, stale-entry removal, edited +subtrees and foreign ownership. The complete installer compiles without executing +an installation. Explorer presentation/click acceptance remains separate. + +## Supersedes + +The installer's flat WSL verb layout; the ordinary Pebrel verb is retained. + +## Revisit when + +Distribution registration must update outside installation, or Explorer requires +a different supported menu registration mechanism. diff --git a/scripts/installer-migration.iss b/scripts/installer-migration.iss index 033dd7d..c281776 100644 --- a/scripts/installer-migration.iss +++ b/scripts/installer-migration.iss @@ -277,13 +277,13 @@ end; { —— 按 WSL 发行版注册的右键项 —— - 「在 Pebrel 中打开(Ubuntu)」这类菜单项按**安装那一刻**机器上的发行版动态生成: + WSL 发行版子菜单按**安装那一刻**机器上的发行版动态生成: `Lxss` 里的 `DistributionName` 是唯一真相,枚举口径与 Rust 侧 `shell_detect::find_wsl_distros` 一致(跳过 `docker-desktop*` 这类 plumbing 发行版)。 走 `[Code]` 而不是 `[Registry]` 的原因只有一个:键的条数取决于机器上有几个发行版。 代价是这些键没有 `uninsdeletekey`,卸载由 `RemoveOwnedWslContextMenus` 按名字前缀 - 加命令串里的可执行文件与 `--shell` 认领后删除。 } + 加精确命令与级联菜单归属标记认领后删除。 } const LxssRoot = 'Software\Microsoft\Windows\CurrentVersion\Lxss'; @@ -308,67 +308,117 @@ begin SetArrayLength(Result, Count); end; -{ 删掉本安装器写下的 WSL 右键项:键名以 `PebrelWsl` 开头、命令串里同时有我们的 - 可执行文件和 `--shell`,两条都满足才算我们写的。发行版后来被移除也不会留下 - 点不动的死项。 } +{ WSL 右键项按精确可执行文件前缀归属;级联菜单还校验所有子项,保留用户编辑。 + 发行版移除后,下次安装重建菜单时不留下旧项。 } +function IsOwnedWslCommand(Command, Executable: string): Boolean; +var + Prefix: string; +begin + Prefix := '"' + Executable + '" --gpui --shell "wsl:'; + Result := CompareText(Copy(Command, 1, Length(Prefix)), Prefix) = 0; +end; + +function IsOwnedWslMenu(Key, Executable: string): Boolean; +var + Owner, Command: string; + Children, Subkeys: TArrayOfString; + Index: Integer; +begin + Result := False; + if not RegQueryStringValue(HKCU, Key, 'PebrelOwner', Owner) or + (CompareText(Owner, Executable) <> 0) then + Exit; + if not RegGetSubkeyNames(HKCU, Key, Subkeys) then + Exit; + for Index := 0 to GetArrayLength(Subkeys) - 1 do + if CompareText(Subkeys[Index], 'shell') <> 0 then + Exit; + if GetArrayLength(Subkeys) > 0 then begin + if not RegGetSubkeyNames(HKCU, Key + '\shell', Children) then + Exit; + for Index := 0 to GetArrayLength(Children) - 1 do begin + if not RegQueryStringValue(HKCU, Key + '\shell\' + Children[Index] + '\command', '', Command) or + not IsOwnedWslCommand(Command, Executable) then + Exit; + end; + end; + Result := True; +end; + +procedure RemoveOwnedWslContextMenusAt(Root, Executable: string); +var + Key, Command: string; + Names: TArrayOfString; + NameIndex: Integer; +begin + if RegGetSubkeyNames(HKCU, Root, Names) then + for NameIndex := 0 to GetArrayLength(Names) - 1 do + if Pos('PebrelWsl', Names[NameIndex]) = 1 then begin + Key := Root + '\' + Names[NameIndex]; + Command := ''; + if IsOwnedWslMenu(Key, Executable) or + (RegQueryStringValue(HKCU, Key + '\command', '', Command) and + IsOwnedWslCommand(Command, Executable)) then + if not RegDeleteKeyIncludingSubkeys(HKCU, Key) then + RaiseException('Unable to remove an owned WSL context menu: ' + Key); + end; +end; + procedure RemoveOwnedWslContextMenus; var Roots: array[0..1] of string; - Root, Key, Command: string; - Names: TArrayOfString; - Index, NameIndex: Integer; + Index: Integer; begin Roots[0] := 'Software\Classes\Directory\shell'; Roots[1] := 'Software\Classes\Directory\Background\shell'; - for Index := 0 to 1 do begin - Root := Roots[Index]; - if RegGetSubkeyNames(HKCU, Root, Names) then - for NameIndex := 0 to GetArrayLength(Names) - 1 do - if Pos('PebrelWsl', Names[NameIndex]) = 1 then begin - Key := Root + '\' + Names[NameIndex]; - Command := ''; - if RegQueryStringValue(HKCU, Key + '\command', '', Command) and - (Pos(ExpandConstant('{app}\pebrel.exe'), Command) > 0) and - (Pos('--shell', Command) > 0) then - if not RegDeleteKeyIncludingSubkeys(HKCU, Key) then - RaiseException('Unable to remove an owned WSL context menu: ' + Key); - end; + for Index := 0 to 1 do + RemoveOwnedWslContextMenusAt(Roots[Index], ExpandConstant('{app}\pebrel.exe')); +end; + +procedure RegisterWslContextMenuAt(Root, Executable, DirectoryArgument: string; + Distros: TArrayOfString); +var + Index: Integer; + Distro, Verb, Command, Key, Menu: string; +begin + Menu := Root + '\PebrelWslMenu'; + { Never overwrite an unknown installation or an edited submenu. } + if RegKeyExists(HKCU, Menu) and not IsOwnedWslMenu(Menu, Executable) then + RaiseException('An unowned or edited WSL context menu was preserved: ' + Menu); + RemoveOwnedWslContextMenusAt(Root, Executable); + if GetArrayLength(Distros) = 0 then + Exit; + if not RegWriteStringValue(HKCU, Menu, 'MUIVerb', CustomMessage('OpenInPebrelWsl')) or + not RegWriteStringValue(HKCU, Menu, 'Icon', Executable + ',0') or + not RegWriteStringValue(HKCU, Menu, 'SubCommands', '') or + not RegWriteStringValue(HKCU, Menu, 'PebrelOwner', Executable) then + RaiseException('Unable to register the WSL context submenu.'); + for Index := 0 to GetArrayLength(Distros) - 1 do begin + Distro := Distros[Index]; + { 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦, + 何况我们靠前缀认领。 } + Verb := 'PebrelWsl' + IntToStr(Index); + { `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾 + 反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面), + 顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 } + Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory '; + Key := Menu + '\shell\' + Verb; + if not RegWriteStringValue(HKCU, Key, 'MUIVerb', Distro) or + not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or + not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"' + DirectoryArgument + '"') then + RaiseException('Unable to register the WSL context menu for ' + Distro + '.'); end; end; procedure RegisterWslContextMenus; var Distros: TArrayOfString; - Index: Integer; - Distro, Verb, LabelText, Executable, Command, Key: string; + Executable: string; begin - { 先清掉上一次写下的:发行版被移除后,残留的索引键会指向一个已经不存在的发行版。 } - RemoveOwnedWslContextMenus; Distros := WslDistroNames; - if GetArrayLength(Distros) = 0 then - Exit; Executable := ExpandConstant('{app}\pebrel.exe'); - for Index := 0 to GetArrayLength(Distros) - 1 do begin - Distro := Distros[Index]; - { 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦, - 何况我们靠前缀认领。 } - Verb := 'PebrelWsl' + IntToStr(Index); - LabelText := CustomMessage('OpenInPebrelWsl') + ' (' + Distro + ')'; - { `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾 - 反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面), - 顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 } - Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory '; - Key := 'Software\Classes\Directory\shell\' + Verb; - if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or - not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or - not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%1"') then - RaiseException('Unable to register the WSL context menu for ' + Distro + '.'); - Key := 'Software\Classes\Directory\Background\shell\' + Verb; - if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or - not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or - not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%V"') then - RaiseException('Unable to register the WSL background context menu for ' + Distro + '.'); - end; + RegisterWslContextMenuAt('Software\Classes\Directory\shell', Executable, '%1', Distros); + RegisterWslContextMenuAt('Software\Classes\Directory\Background\shell', Executable, '%V', Distros); end; procedure MigrateLegacyIntegrations; diff --git a/scripts/installer.iss b/scripts/installer.iss index 517365a..bd5f026 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -77,7 +77,7 @@ english.AutoStart=Start Pebrel when I sign in to Windows english.InstallFont=Install Maple Mono font for the current user english.AddToPath=Add Pebrel to the user PATH english.OpenInPebrel=Open in Pebrel -english.OpenInPebrelWsl=Open in Pebrel +english.OpenInPebrelWsl=Open in Pebrel (WSL) english.LaunchProgram=Launch Pebrel english.UninstallProgram=Uninstall Pebrel english.CloseLegacyProgram=Close the application at %1, then retry the installation. @@ -89,7 +89,7 @@ chinesesimplified.AutoStart=登录 Windows 后启动 Pebrel chinesesimplified.InstallFont=为当前用户安装 Maple Mono 字体 chinesesimplified.AddToPath=将 Pebrel 添加到当前用户 PATH chinesesimplified.OpenInPebrel=在 Pebrel 中打开 -chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开 +chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开(WSL) chinesesimplified.LaunchProgram=启动 Pebrel chinesesimplified.UninstallProgram=卸载 Pebrel chinesesimplified.CloseLegacyProgram=请关闭 %1 中运行的程序,然后重试安装。 diff --git a/scripts/tests/installer-migration-fixture.iss b/scripts/tests/installer-migration-fixture.iss index 1adb965..69a2aaa 100644 --- a/scripts/tests/installer-migration-fixture.iss +++ b/scripts/tests/installer-migration-fixture.iss @@ -1,4 +1,4 @@ -#ifndef FixtureRoot +#ifndef FixtureRoot #error FixtureRoot must point at an isolated directory under the workspace tmp directory. #endif #define MigrationFixture @@ -15,6 +15,9 @@ OutputBaseFilename=installer-migration-fixture Compression=none SetupLogging=no +[CustomMessages] +OpenInPebrelWsl=Open in Pebrel (WSL) + [Code] #include "..\installer-migration.iss" @@ -180,6 +183,69 @@ begin Check(FileExists(NewLink), 'shortcut pointing at another program is preserved'); end; +procedure CheckContextMenus(Root: string); +var + RegistryRoot, ShellRoot, Executable, Value, Menu, ForeignCommand: string; + Distros, Names: TArrayOfString; + Index: Integer; + Failed: Boolean; +begin + RegistryRoot := 'Software\PebrelTestFixtures\' + ExtractFileName(Root); + Executable := Root + '\Program Files\pebrel.exe'; + ForeignCommand := '"D:\other\pebrel.exe" --gpui --shell "wsl:Other" --working-directory "%1"'; + try + for Index := 0 to 1 do begin + ShellRoot := RegistryRoot + '\root' + IntToStr(Index); + Menu := ShellRoot + '\PebrelWslMenu'; + Check(RegWriteStringValue(HKCU, ShellRoot + '\Pebrel\command', '', 'ordinary-open'), 'seed ordinary entry'); + Check(RegWriteStringValue(HKCU, ShellRoot + '\PebrelWslForeign\command', '', ForeignCommand), 'seed foreign entry'); + Check(RegWriteStringValue(HKCU, ShellRoot + '\PebrelWsl0\command', '', + '"' + Executable + '" --gpui --shell "wsl:Old" --working-directory "%1"'), 'seed old flat entry'); + SetArrayLength(Distros, 3); + Distros[0] := 'Debian'; + Distros[1] := 'Ubuntu Test'; + Distros[2] := '开发环境'; + if Index = 0 then Value := '%1' else Value := '%V'; + RegisterWslContextMenuAt(ShellRoot, Executable, Value, Distros); + Check(not RegKeyExists(HKCU, ShellRoot + '\PebrelWsl0'), 'migrate old flat entry'); + Check(RegGetSubkeyNames(HKCU, ShellRoot, Names) and (GetArrayLength(Names) = 3), 'only normal, cascade and foreign roots remain'); + Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 3), 'three distros live inside cascade'); + Check(RegQueryStringValue(HKCU, Menu, 'SubCommands', Value) and (Value = ''), 'enable static cascade'); + Check(RegQueryStringValue(HKCU, Menu + '\shell\PebrelWsl1\command', '', Value), 'child command exists'); + if Index = 0 then + Check(Value = '"' + Executable + '" --gpui --shell "wsl:Ubuntu Test" --working-directory "%1"', 'selected directory argv') + else + Check(Value = '"' + Executable + '" --gpui --shell "wsl:Ubuntu Test" --working-directory "%V"', 'background directory argv'); + Check(RegQueryStringValue(HKCU, Menu + '\shell\PebrelWsl2', 'MUIVerb', Value) and (Value = '开发环境'), 'Unicode label retained'); + RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros); + Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 3), 'repeat registration has no duplicates'); + SetArrayLength(Distros, 1); + RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros); + Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 1), 'removed distros leave no stale entries'); + Check(RegWriteStringValue(HKCU, Menu + '\shell\Custom\command', '', ForeignCommand), 'seed edited submenu'); + Failed := False; + try + RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros); + except + Failed := True; + end; + Check(Failed and RegKeyExists(HKCU, Menu + '\shell\Custom'), 'edited submenu is not overwritten'); + RemoveOwnedWslContextMenusAt(ShellRoot, Executable); + Check(RegKeyExists(HKCU, Menu + '\shell\Custom'), 'uninstall preserves edited subtree'); + Check(RegDeleteKeyIncludingSubkeys(HKCU, Menu + '\shell\Custom'), 'remove fixture edit'); + RemoveOwnedWslContextMenusAt(ShellRoot, 'D:\other\install.exe'); + Check(RegKeyExists(HKCU, Menu), 'other installation cannot remove this menu'); + SetArrayLength(Distros, 0); + RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros); + Check(not RegKeyExists(HKCU, Menu), 'zero distros removes empty cascade'); + Check(RegQueryStringValue(HKCU, ShellRoot + '\PebrelWslForeign\command', '', Value) and (Value = ForeignCommand), 'foreign entry preserved'); + Check(RegQueryStringValue(HKCU, ShellRoot + '\Pebrel\command', '', Value) and (Value = 'ordinary-open'), 'ordinary entry preserved'); + end; + finally + RegDeleteKeyIncludingSubkeys(HKCU, RegistryRoot); + end; +end; + function InitializeSetup: Boolean; var Root, Report: string; @@ -190,6 +256,7 @@ begin CheckPayloadMigration(Root); CheckFailures(Root); CheckShortcuts(Root); + CheckContextMenus(Root); Report := 'PASS: ' + IntToStr(Checks) + ' migration checks'; except Report := 'FAIL: ' + GetExceptionMessage;