Files
pebrel/scripts/installer-migration.iss
T
kuddev 12f0ef278f fix(updater): preserve workspaces through verified installation handoff
Persist native AI recovery targets and per-pane launch contexts without treating
command submission as provider confirmation. Keep window partitions and the
active window in the durable session snapshot for immediate and scheduled updates.

Coordinate draft approval, snapshot writes, helper readiness, process exit and
installation with a committed transaction. Reverify packages and retain recovery
state on failure. Isolate acceptance installers from production integrations and
reject same-version setup that leaves the executable unchanged.

Validation: Windows GPUI suite 1552 passed; settings 55 passed; i18n 20 passed;
9 native helper scenarios; 77 installer migration checks and 41 static invariants.
Full interactive packaged upgrade acceptance remains separate.
2026-09-15 22:41:58 +08:00

371 lines
14 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;
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) or (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