Files
pebrel/scripts/installer-migration.iss

521 lines
20 KiB
Plaintext

// Included by the product installer and the isolated migration fixture.
const
#ifdef AcceptanceFixture
ProductUninstallKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{76B778B5-76C6-4F60-9431-9E67C2A351AF}_is1';
ProductSettingsKey = 'Software\PebrelUpdateAcceptance';
LegacySettingsKey = 'Software\PebrelUpdateAcceptanceLegacy';
#else
ProductUninstallKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{61022144-7D0A-4E54-94F2-C329A8F58656}_is1';
ProductSettingsKey = 'Software\Pebrel';
LegacySettingsKey = 'Software\Nebula Terminal';
#endif
var
PreviousInstallDir: string;
LegacyInstallDir: string;
LegacyUninstaller: string;
MigrationFailed: Boolean;
function MigrationFileAttributes(FileName: string): LongWord;
external 'GetFileAttributesW@kernel32.dll stdcall';
function NormalizedDirectory(Value: string): string;
begin
Result := RemoveBackslashUnlessRoot(ExpandFileName(RemoveQuotes(Trim(Value))));
end;
function SameDirectory(Left, Right: string): Boolean;
begin
Result := CompareText(NormalizedDirectory(Left), NormalizedDirectory(Right)) = 0;
end;
function SuggestedInstallDir(Previous, Fallback: string): string;
begin
Result := Fallback;
if Previous = '' then
Exit;
Result := NormalizedDirectory(Previous);
if CompareText(ExtractFileName(Result), 'Nebula Terminal') = 0 then
Result := AddBackslash(ExtractFileDir(Result)) + 'Pebrel';
end;
function PathContainsDirectory(Value, Directory: string): Boolean;
var
Entry: string;
Separator: Integer;
begin
Result := False;
repeat
Separator := Pos(';', Value);
if Separator = 0 then
Separator := Length(Value) + 1;
Entry := Copy(Value, 1, Separator - 1);
if (Trim(Entry) <> '') and SameDirectory(Entry, Directory) then begin
Result := True;
Exit;
end;
Delete(Value, 1, Separator);
until Value = '';
end;
function RemovePathDirectory(Value, Directory: string): string;
var
Entry: string;
Separator: Integer;
FirstEntry: Boolean;
begin
Result := '';
FirstEntry := True;
repeat
Separator := Pos(';', Value);
if Separator = 0 then
Separator := Length(Value) + 1;
Entry := Copy(Value, 1, Separator - 1);
if (Trim(Entry) = '') or not SameDirectory(Entry, Directory) then begin
if not FirstEntry then
Result := Result + ';';
Result := Result + Entry;
FirstEntry := False;
end;
Delete(Value, 1, Separator);
until Value = '';
end;
function IsLegacyUninstaller(Directory, FileName: string): Boolean;
var
Name: string;
Index: Integer;
begin
Name := Lowercase(ExtractFileName(FileName));
Result := SameDirectory(ExtractFileDir(FileName), Directory) and
(Length(Name) = 12) and (Copy(Name, 1, 5) = 'unins') and
(Copy(Name, 9, 4) = '.exe');
if Result then
for Index := 6 to 8 do
if (Name[Index] < '0') or (Name[Index] > '9') then
Result := False;
end;
function DefaultInstallDir(Param: string): string;
begin
Result := SuggestedInstallDir(PreviousInstallDir,
ExpandConstant('{localappdata}\Programs\Pebrel'));
end;
procedure InitializeWizard;
begin
{ Reuse the registered directory for normal upgrades, so Inno recognizes it
as an existing installation. Keep the old-brand relocation suggestion and
an explicit /DIR selection intact. }
if (PreviousInstallDir <> '') and
(CompareText(ExtractFileName(NormalizedDirectory(PreviousInstallDir)), 'Nebula Terminal') = 0) and
(ExpandConstant('{param:DIR|}') = '') then
WizardForm.DirEdit.Text := SuggestedInstallDir(PreviousInstallDir,
ExpandConstant('{localappdata}\Programs\Pebrel'));
end;
procedure DiscoverLegacyInstallation;
var
Pending, UninstallCommand: string;
begin
RegQueryStringValue(HKCU, ProductUninstallKey, 'Inno Setup: App Path', PreviousInstallDir);
if (PreviousInstallDir <> '') and
FileExists(AddBackslash(PreviousInstallDir) + 'nebula.exe') then begin
LegacyInstallDir := NormalizedDirectory(PreviousInstallDir);
if RegQueryStringValue(HKCU, ProductUninstallKey, 'UninstallString', UninstallCommand) then begin
UninstallCommand := RemoveQuotes(Trim(UninstallCommand));
if IsLegacyUninstaller(LegacyInstallDir, UninstallCommand) then
LegacyUninstaller := UninstallCommand;
end;
end;
if RegQueryStringValue(HKCU, ProductSettingsKey, 'PendingLegacyInstallDir', Pending) then begin
if Trim(Pending) = '' then
RaiseException('The pending legacy installation directory is empty.');
if (LegacyInstallDir <> '') and not SameDirectory(LegacyInstallDir, Pending) then
RaiseException('Another legacy installation is awaiting migration: ' + Pending);
LegacyInstallDir := NormalizedDirectory(Pending);
RegQueryStringValue(HKCU, ProductSettingsKey, 'PendingLegacyUninstaller', LegacyUninstaller);
if not IsLegacyUninstaller(LegacyInstallDir, LegacyUninstaller) then
LegacyUninstaller := '';
end;
end;
function IsExecutableRunning(FileName: string): Boolean;
var
Locator, Service, Processes, Process: Variant;
Index: Integer;
Name: string;
begin
Result := False;
if not FileExists(FileName) then
Exit;
Name := ExtractFileName(FileName);
StringChangeEx(Name, '''', '''''', True);
Locator := CreateOleObject('WbemScripting.SWbemLocator');
Service := Locator.ConnectServer('', 'root\CIMV2');
Processes := Service.ExecQuery('SELECT ExecutablePath FROM Win32_Process WHERE Name = ''' + Name + '''');
for Index := 0 to Processes.Count - 1 do begin
Process := Processes.ItemIndex(Index);
if not VarIsNull(Process.ExecutablePath) then
if SameDirectory(Process.ExecutablePath, FileName) then begin
Result := True;
Exit;
end;
end;
end;
function SafePayloadPath(Root, Relative: string): string;
var
Part, Parent: string;
Attributes: LongWord;
begin
Root := NormalizedDirectory(Root);
Result := ExpandFileName(AddBackslash(Root) + Relative);
if Pos(Lowercase(AddBackslash(Root)), Lowercase(Result)) <> 1 then
RaiseException('Invalid legacy payload path: ' + Result);
Part := Result;
repeat
Attributes := MigrationFileAttributes(Part);
if (Attributes <> $FFFFFFFF) and ((Attributes and $400) <> 0) then
RaiseException('Linked legacy installation path must be migrated manually: ' + Part);
Parent := ExtractFileDir(Part);
if SameDirectory(Parent, Part) then
Break;
Part := Parent;
until Part = '';
end;
procedure RemoveLegacyFile(Root, Relative: string);
var
FileName: string;
begin
FileName := SafePayloadPath(Root, Relative);
if FileExists(FileName) and not DeleteFile(FileName) then
RaiseException('Unable to remove old installation file: ' + FileName);
end;
procedure RemoveEmptyLegacyDir(Root, Relative: string);
var
Directory: string;
begin
Directory := SafePayloadPath(Root, Relative);
if DirExists(Directory) then
RemoveDir(Directory);
end;
procedure RemoveLegacyPayload(OldDir, NewDir, Uninstaller: string);
begin
if OldDir = '' then
Exit;
RemoveLegacyFile(OldDir, 'runtime\nebula-hook.exe');
RemoveLegacyFile(OldDir, 'nebula-hook.exe');
RemoveLegacyFile(OldDir, 'skills\nebula-runtime\agents\openai.yaml');
RemoveLegacyFile(OldDir, 'skills\nebula-runtime\SKILL.md');
RemoveEmptyLegacyDir(OldDir, 'skills\nebula-runtime\agents');
RemoveEmptyLegacyDir(OldDir, 'skills\nebula-runtime');
if not SameDirectory(OldDir, NewDir) then begin
RemoveLegacyFile(OldDir, 'runtime\conpty.dll');
RemoveLegacyFile(OldDir, 'runtime\OpenConsole.exe');
RemoveLegacyFile(OldDir, 'conpty.dll');
RemoveLegacyFile(OldDir, 'OpenConsole.exe');
RemoveLegacyFile(OldDir, 'README.md');
RemoveLegacyFile(OldDir, 'docs\CHANGELOG.md');
RemoveLegacyFile(OldDir, 'docs\INSTALL.md');
RemoveLegacyFile(OldDir, 'docs\lua-configuration.md');
RemoveLegacyFile(OldDir, 'docs\runtime-control-api.md');
RemoveLegacyFile(OldDir, 'docs\runtime-api-v1.schema.json');
RemoveLegacyFile(OldDir, 'fonts\MapleMonoNormal-NF-CN-Regular.ttf');
RemoveLegacyFile(OldDir, 'licenses\LICENSE');
RemoveLegacyFile(OldDir, 'licenses\LICENSE-LUA');
RemoveLegacyFile(OldDir, 'licenses\LICENSE-MLUA');
RemoveLegacyFile(OldDir, 'licenses\THIRD-PARTY-NOTICES');
if IsLegacyUninstaller(OldDir, Uninstaller) then begin
RemoveLegacyFile(OldDir, ExtractFileName(Uninstaller));
RemoveLegacyFile(OldDir, ChangeFileExt(ExtractFileName(Uninstaller), '.dat'));
RemoveLegacyFile(OldDir, ChangeFileExt(ExtractFileName(Uninstaller), '.msg'));
end;
RemoveEmptyLegacyDir(OldDir, 'runtime');
RemoveEmptyLegacyDir(OldDir, 'docs');
RemoveEmptyLegacyDir(OldDir, 'fonts');
RemoveEmptyLegacyDir(OldDir, 'licenses');
RemoveEmptyLegacyDir(OldDir, 'skills');
end;
RemoveLegacyFile(OldDir, 'nebula.exe');
if not SameDirectory(OldDir, NewDir) then
RemoveDir(OldDir);
end;
procedure RemoveOwnedShortcut(FileName, Target: string);
var
Shell, Shortcut: Variant;
begin
if not FileExists(FileName) then
Exit;
Shell := CreateOleObject('WScript.Shell');
Shortcut := Shell.CreateShortcut(FileName);
if SameDirectory(Shortcut.TargetPath, Target) and not DeleteFile(FileName) then
RaiseException('Unable to remove old shortcut: ' + FileName);
end;
procedure RemoveOwnedRegistryKey(Key, ValueName, Expected: string);
var
Value: string;
begin
if RegQueryStringValue(HKCU, Key, ValueName, Value) and
(CompareText(Value, Expected) = 0) and not RegDeleteKeyIncludingSubkeys(HKCU, Key) then
RaiseException('Unable to remove old registration: ' + Key);
end;
procedure RemoveOwnedContextMenu(Key, ExpectedCommand: string);
var
Command: string;
begin
if RegQueryStringValue(HKCU, Key + '\command', '', Command) and
(CompareText(Command, ExpectedCommand) = 0) and not RegDeleteKeyIncludingSubkeys(HKCU, Key) then
RaiseException('Unable to remove old context menu: ' + Key);
end;
{ —— 按 WSL 发行版注册的右键项 ——
WSL 发行版子菜单按**安装那一刻**机器上的发行版动态生成:
`Lxss` 里的 `DistributionName` 是唯一真相,枚举口径与 Rust 侧
`shell_detect::find_wsl_distros` 一致(跳过 `docker-desktop*` 这类 plumbing 发行版)。
走 `[Code]` 而不是 `[Registry]` 的原因只有一个:键的条数取决于机器上有几个发行版。
代价是这些键没有 `uninsdeletekey`,卸载由 `RemoveOwnedWslContextMenus` 按名字前缀
加精确命令与级联菜单归属标记认领后删除。 }
const
LxssRoot = 'Software\Microsoft\Windows\CurrentVersion\Lxss';
function WslDistroNames: TArrayOfString;
var
Names: TArrayOfString;
Index, Count: Integer;
Name: string;
begin
SetArrayLength(Result, 0);
if not RegGetSubkeyNames(HKCU, LxssRoot, Names) then
Exit;
SetArrayLength(Result, GetArrayLength(Names));
Count := 0;
for Index := 0 to GetArrayLength(Names) - 1 do
if RegQueryStringValue(HKCU, LxssRoot + '\' + Names[Index], 'DistributionName', Name) then
if (Name <> '') and (Pos('docker-desktop', Lowercase(Name)) <> 1) then begin
Result[Count] := Name;
Count := Count + 1;
end;
SetArrayLength(Result, Count);
end;
{ 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;
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(FmtMessage(CustomMessage('WslMenuConflict'), [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(CustomMessage('WslMenuRegistrationFailed'));
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;
Executable: string;
begin
Distros := WslDistroNames;
Executable := ExpandConstant('{app}\pebrel.exe');
RegisterWslContextMenuAt('Software\Classes\Directory\shell', Executable, '%1', Distros);
RegisterWslContextMenuAt('Software\Classes\Directory\Background\shell', Executable, '%V', Distros);
end;
procedure MigrateLegacyIntegrations;
var
Executable, ExistingPath, Key: string;
begin
Executable := AddBackslash(LegacyInstallDir) + 'nebula.exe';
RemoveOwnedShortcut(ExpandConstant('{autodesktop}\Nebula Terminal.lnk'), Executable);
RemoveOwnedShortcut(ExpandConstant('{autodesktop}\Pebrel.lnk'), Executable);
RemoveOwnedShortcut(ExpandConstant('{userstartup}\Nebula Terminal.lnk'), Executable);
RemoveOwnedShortcut(ExpandConstant('{userstartup}\Pebrel.lnk'), Executable);
RemoveOwnedShortcut(ExpandConstant('{userprograms}\Nebula Terminal\Nebula Terminal.lnk'), Executable);
RemoveOwnedShortcut(ExpandConstant('{userprograms}\Pebrel\Pebrel.lnk'), Executable);
if LegacyUninstaller <> '' then begin
RemoveOwnedShortcut(ExpandConstant('{userprograms}\Nebula Terminal\Uninstall Nebula Terminal.lnk'), LegacyUninstaller);
RemoveOwnedShortcut(ExpandConstant('{userprograms}\Nebula Terminal\卸载 Nebula Terminal.lnk'), LegacyUninstaller);
end;
RemoveDir(ExpandConstant('{userprograms}\Nebula Terminal'));
RemoveOwnedRegistryKey('Software\Microsoft\Windows\CurrentVersion\App Paths\nebula.exe', '', Executable);
Key := 'Software\Classes\Directory\Background\shell\NebulaTerminal';
RemoveOwnedContextMenu(Key, '"' + Executable + '" --gpui --working-directory "%V"');
Key := 'Software\Classes\Directory\shell\NebulaTerminal';
RemoveOwnedContextMenu(Key, '"' + Executable + '" --gpui --working-directory "%1"');
if RegValueExists(HKCU, LegacySettingsKey, 'InstallerAddedToPath') then begin
if SameDirectory(LegacyInstallDir, ExpandConstant('{app}')) then begin
if not RegWriteDWordValue(HKCU, ProductSettingsKey, 'InstallerAddedToPath', 1) then
RaiseException('Unable to transfer PATH ownership.');
end else if RegQueryStringValue(HKCU, 'Environment', 'Path', ExistingPath) then begin
if not RegWriteExpandStringValue(HKCU, 'Environment', 'Path',
RemovePathDirectory(ExistingPath, LegacyInstallDir)) then
RaiseException('Unable to remove old installation directory from PATH.');
end;
if not RegDeleteValue(HKCU, LegacySettingsKey, 'InstallerAddedToPath') then
RaiseException('Unable to clear previous PATH ownership.');
RegDeleteKeyIfEmpty(HKCU, LegacySettingsKey);
end;
end;
#ifndef MigrationFixture
function InitializeSetup: Boolean;
begin
Result := True;
try
DiscoverLegacyInstallation;
except
Result := False;
SuppressibleMsgBox(FmtMessage(CustomMessage('MigrationPreflightFailed'), [GetExceptionMessage]),
mbError, MB_OK, IDOK);
end;
end;
function PrepareToInstall(var NeedsRestart: Boolean): string;
var
Executable: string;
begin
Result := '';
if LegacyInstallDir = '' then
Exit;
try
Executable := SafePayloadPath(LegacyInstallDir, 'nebula.exe');
if IsExecutableRunning(Executable) then
Result := FmtMessage(CustomMessage('CloseLegacyProgram'), [Executable]);
except
Result := FmtMessage(CustomMessage('MigrationPreflightFailed'), [GetExceptionMessage]);
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep <> ssPostInstall then
Exit;
{ 与旧版迁移无关,装完就要做:菜单项取决于本机装了哪些 WSL 发行版。 }
RegisterWslContextMenus;
if LegacyInstallDir = '' then
Exit;
try
if not RegWriteStringValue(HKCU, ProductSettingsKey, 'PendingLegacyInstallDir', LegacyInstallDir) or
not RegWriteStringValue(HKCU, ProductSettingsKey, 'PendingLegacyUninstaller', LegacyUninstaller) then
RaiseException('Unable to record the previous installation for migration.');
MigrateLegacyIntegrations;
RemoveLegacyPayload(LegacyInstallDir, ExpandConstant('{app}'), LegacyUninstaller);
if not RegDeleteValue(HKCU, ProductSettingsKey, 'PendingLegacyInstallDir') or
not RegDeleteValue(HKCU, ProductSettingsKey, 'PendingLegacyUninstaller') then
RaiseException('Unable to clear the completed migration record.');
except
MigrationFailed := True;
Log('Legacy migration failed: ' + GetExceptionMessage);
SuppressibleMsgBox(FmtMessage(CustomMessage('MigrationFailed'), [GetExceptionMessage]),
mbError, MB_OK, IDOK);
end;
end;
function GetCustomSetupExitCode: Integer;
begin
Result := 0;
if MigrationFailed then
Result := 1;
end;
#endif