fix(installer): group WSL distributions under one context submenu

This commit is contained in:
kuddev committed 2026-09-23 13:33:14 +08:00
1 parent 0110c7c139
commit e054b09771
4 files changed
+223 -47

No files matched your search

@@ -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<index>` 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.
+94 -44
View File
@@ -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`,两条都满足才算我们写的。发行版后来被移除也不会留下
点不动的死项。 }
procedure RemoveOwnedWslContextMenus;
{ WSL 右键项按精确可执行文件前缀归属;级联菜单还校验所有子项,保留用户编辑。
发行版移除后,下次安装重建菜单时不留下旧项。 }
function IsOwnedWslCommand(Command, Executable: string): Boolean;
var
Roots: array[0..1] of string;
Root, Key, Command: string;
Names: TArrayOfString;
Index, NameIndex: Integer;
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
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 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;
Index: Integer;
begin
Roots[0] := 'Software\Classes\Directory\shell';
Roots[1] := 'Software\Classes\Directory\Background\shell';
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;
+2 -2
View File
@@ -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 中运行的程序,然后重试安装。
+68 -1
View File
@@ -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;