fix(windows): elevate the installer when the install directory is read-only

PrivilegesRequired=lowest makes Inno Setup "always run in non
administrative install mode" — the launching token is irrelevant. So a
copy that ended up in C:\Program Files, which the destination page still
lets an elevated wizard run pick, is registered under HKCU while living
somewhere an ordinary process cannot write. UsePreviousAppDir then aims
every later run straight back at that directory.

WinSparkle launches the downloaded installer with plain ShellExecuteEx
and no verb, so nothing along the in-app update path ever asks for
elevation: the silent installer starts, cannot replace a single file, and
the only way out was to quit Plezy, fetch the installer by hand and pick
"Run as administrator". Inno's own PrivilegesRequiredOverridesAllowed
plus UsePreviousPrivileges does not help here, because it reads the
recorded install mode — which is exactly the non-administrative one that
cannot write.

Decide on write access instead. InitializeSetup probes the registered
install directory and, when it is not writable, relaunches setup through
ShellExec 'runas' pinned to that directory with /ALLUSERS, so the update
lands in place instead of forking a second per-user copy. The relaunch
carries a guard parameter and drops any conflicting mode override, and a
refused UAC prompt now explains itself and points at the releases page
rather than failing mutely. A machine-wide install that takes over a
per-user directory also clears the stale uninstall entry and Start Menu
group that would otherwise list Plezy twice in Apps & Features.

Fresh installs are unchanged: still per-user, still no prompt. Only
commandline is added to PrivilegesRequiredOverridesAllowed, since
allowing dialog would make a silent install with no previous copy stop
for the install-mode question — which is how winget installs.

The script carried two near-identical copies of the whole .iss, one per
architecture shape, so both would have needed this code. Collapse them
into one template parameterised by architecture, add -EmitScriptOnly to
generate the .iss without 7-Zip or Inno Setup, and guard the contract
with check_windows_installer.py so the elevation path, the single-source
AppId and the winget marker cannot rot.

close #1705
This commit is contained in:
edde746
2026-07-28 23:57:59 +02:00
parent 726dfc6507
commit 1165998dae
4 changed files with 504 additions and 146 deletions
+230 -145
View File
@@ -7,9 +7,225 @@ param(
[string]$OutputDir = ".",
[string]$Version = "1.0.0",
[string]$X64BuildDir,
[string]$Arm64BuildDir
[string]$Arm64BuildDir,
# Write setup.iss and stop. Lets the generated script be inspected or
# checked without 7-Zip, Inno Setup or a populated Flutter build output.
[switch]$EmitScriptOnly
)
function New-InnoSetupScript {
param(
[Parameter(Mandatory)][string]$Version,
[Parameter(Mandatory)][bool]$HasX64,
[Parameter(Mandatory)][bool]$HasArm64
)
# Uninstall registry keys are named "{AppId}_is1", so the installer and the
# elevation code below have to agree on this GUID.
$AppGuid = '4213385e-f7be-4f2b-95f9-54082a28bb8f'
if ($HasX64 -and $HasArm64) {
$ArchAllowed = 'x64compatible arm64'
$FilesSection = @'
Source: "staging\x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Check: IsX64
Source: "staging\arm64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs solidbreak; Check: IsArm64
'@
} elseif ($HasX64) {
$ArchAllowed = 'x64compatible'
$FilesSection = 'Source: "staging\x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs'
} else {
$ArchAllowed = 'arm64'
$FilesSection = 'Source: "staging\arm64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs'
}
return @"
#define Name "Plezy"
#define Version "$Version"
#define Publisher "edde746"
#define ExeName "plezy.exe"
[Setup]
AppId={{$AppGuid}
AppName={#Name}
AppVersion={#Version}
AppPublisher={#Publisher}
DefaultDirName={autopf}\{#Name}
DefaultGroupName={#Name}
AllowNoIcons=yes
OutputDir=.
OutputBaseFilename=plezy-windows-installer
Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=lowest
; Needed for /ALLUSERS to take effect, which is how the elevated instance
; started by InitializeSetup below reaches an existing machine-wide install.
PrivilegesRequiredOverridesAllowed=commandline
ArchitecturesAllowed=$ArchAllowed
ArchitecturesInstallIn64BitMode=$ArchAllowed
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[CustomMessages]
ElevationRequired=Plezy is installed in %1, which requires administrator privileges to update.%n%nRe-run this installer using "Run as administrator", or download the latest installer from https://github.com/edde746/plezy/releases/latest
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
$FilesSection
[Icons]
Name: "{group}\{#Name}"; Filename: "{app}\{#ExeName}"
Name: "{group}\{cm:UninstallProgram,{#Name}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#Name}"; Filename: "{app}\{#ExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#ExeName}"; Description: "{cm:LaunchProgram,{#Name}}"; Flags: nowait postinstall; Check: not IsNoRun
[Code]
const
UninstallSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{$AppGuid}_is1';
WriteProbeName = 'plezy-write-probe.tmp';
function IsNoRun: Boolean;
begin
Result := ExpandConstant('{param:NORUN|0}') = '1';
end;
function IsX64: Boolean;
begin
Result := not IsArm64;
end;
{ Directory of an existing installation, or '' when none is registered.
PrivilegesRequired=lowest pins Setup to non administrative install mode, so
Inno's own UsePreviousAppDir lookup only ever consults HKCU. A copy that
ended up machine-wide has to be found whichever mode registered it. }
function PreviousInstallDir: String;
var
Dir: String;
begin
Result := '';
if RegQueryStringValue(HKCU, UninstallSubkey, 'Inno Setup: App Path', Dir) then
Result := Dir
else if RegQueryStringValue(HKLM, UninstallSubkey, 'Inno Setup: App Path', Dir) then
Result := Dir;
end;
{ Whether this process could replace files in Path. Setup is manifested, so UAC
file virtualization is off and a refused write really is refused. }
function PathIsWritable(const Path: String): Boolean;
var
Dir, Probe: String;
begin
Dir := RemoveBackslashUnlessRoot(Path);
if not DirExists(Dir) then
Dir := ExtractFileDir(Dir);
if (Dir = '') or not DirExists(Dir) then begin
{ Nothing to overwrite; let Setup report any genuine failure itself. }
Result := True;
Exit;
end;
Probe := AddBackslash(Dir) + WriteProbeName;
Result := SaveStringToFile(Probe, '', False);
if Result then
DeleteFile(Probe);
end;
function QuoteIfNeeded(const S: String): String;
begin
if Pos(' ', S) > 0 then
Result := '"' + S + '"'
else
Result := S;
end;
{ The documented parameters this instance was started with, minus the install
mode and directory overrides the elevated instance is given explicitly. }
function ForwardedParams: String;
var
I: Integer;
P: String;
begin
Result := '';
for I := 1 to ParamCount do begin
P := ParamStr(I);
if (P <> '') and
(CompareText(P, '/ALLUSERS') <> 0) and
(CompareText(P, '/CURRENTUSER') <> 0) and
(CompareText(Copy(P, 1, 5), '/DIR=') <> 0) then
Result := Result + QuoteIfNeeded(P) + ' ';
end;
end;
{ An installation living somewhere this user cannot write - typically
C:\Program Files, inherited from an elevated run of an earlier installer -
can only be updated in administrative install mode. Setup settles the install
mode before any [Code] runs, so hand the work to a new elevated instance and
pin it to the directory already in use. Without this the silent installer
launched by the in-app updater fails to overwrite anything. }
function InitializeSetup: Boolean;
var
PreviousDir, Params: String;
ErrorCode: Integer;
begin
Result := True;
if IsAdminInstallMode or (ExpandConstant('{param:ELEVATED|0}') = '1') then
Exit;
PreviousDir := PreviousInstallDir;
if (PreviousDir = '') or PathIsWritable(PreviousDir) then
Exit;
Params := ForwardedParams + '/ALLUSERS /ELEVATED=1 /DIR=' +
QuoteIfNeeded(RemoveBackslashUnlessRoot(PreviousDir));
{ Either the elevated instance takes over, or elevation was refused and there
is nothing this instance can usefully do. }
Result := False;
if ShellExec('runas', ExpandConstant('{srcexe}'), Params, '', SW_SHOW, ewNoWait, ErrorCode) then
Exit;
SuppressibleMsgBox(FmtMessage(CustomMessage('ElevationRequired'), [PreviousDir]),
mbCriticalError, MB_OK, IDOK);
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
MarkerPath, PreviousDir, PreviousGroup: String;
begin
if CurStep = ssPostInstall then
begin
MarkerPath := ExpandConstant('{app}\.winget');
if ExpandConstant('{param:WINGET|0}') = '1' then
SaveStringToFile(MarkerPath, '', False)
else
DeleteFile(MarkerPath);
{ A machine-wide install that took over a directory registered per-user
leaves that user's uninstall entry and Start Menu group pointing at files
this install now owns, listing Plezy twice in Apps & Features. }
if IsAdminInstallMode then
begin
if RegQueryStringValue(HKCU, UninstallSubkey, 'Inno Setup: App Path', PreviousDir) and
(CompareText(RemoveBackslashUnlessRoot(PreviousDir),
RemoveBackslashUnlessRoot(ExpandConstant('{app}'))) = 0) then
begin
if not RegQueryStringValue(HKCU, UninstallSubkey, 'Inno Setup: Icon Group', PreviousGroup) then
PreviousGroup := '';
RegDeleteKeyIncludingSubkeys(HKCU, UninstallSubkey);
if PreviousGroup <> '' then
DelTree(ExpandConstant('{userprograms}') + '\' + PreviousGroup, True, True, True);
end;
end;
end;
end;
"@
}
$ErrorActionPreference = "Stop"
Write-Host "Building Windows installer packages..." -ForegroundColor Cyan
@@ -41,6 +257,17 @@ Write-Host "Architectures found:" -ForegroundColor Green
if ($HasX64) { Write-Host " x64: $X64BuildDir" }
if ($HasArm64) { Write-Host " arm64: $Arm64BuildDir" }
$SetupScript = "setup.iss"
if ($EmitScriptOnly) {
$EmittedScript = Join-Path $ResolvedOutput $SetupScript
Write-Host "`nGenerating Inno Setup script only..." -ForegroundColor Cyan
New-InnoSetupScript -Version $Version -HasX64 ([bool]$HasX64) -HasArm64 ([bool]$HasArm64) |
Out-File -FilePath $EmittedScript -Encoding ASCII
Write-Host "Created: $EmittedScript" -ForegroundColor Green
exit 0
}
# Check for 7-Zip
Write-Host "`nChecking for 7-Zip..." -ForegroundColor Cyan
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) {
@@ -105,150 +332,8 @@ if ($HasArm64) {
# Generate Inno Setup Script
Write-Host "`nGenerating Inno Setup script..." -ForegroundColor Cyan
$SetupScript = "setup.iss"
$DualArch = $HasX64 -and $HasArm64
if ($DualArch) {
# Dual-arch unified installer with architecture detection
$IssContent = @"
#define Name "Plezy"
#define Version "$Version"
#define Publisher "edde746"
#define ExeName "plezy.exe"
[Setup]
AppId={{4213385e-f7be-4f2b-95f9-54082a28bb8f}
AppName={#Name}
AppVersion={#Version}
AppPublisher={#Publisher}
DefaultDirName={autopf}\{#Name}
DefaultGroupName={#Name}
AllowNoIcons=yes
OutputDir=.
OutputBaseFilename=plezy-windows-installer
Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible arm64
ArchitecturesInstallIn64BitMode=x64compatible arm64
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "staging\x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Check: IsX64
Source: "staging\arm64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs solidbreak; Check: IsArm64
[Icons]
Name: "{group}\{#Name}"; Filename: "{app}\{#ExeName}"
Name: "{group}\{cm:UninstallProgram,{#Name}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#Name}"; Filename: "{app}\{#ExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#ExeName}"; Description: "{cm:LaunchProgram,{#Name}}"; Flags: nowait postinstall; Check: not IsNoRun
[Code]
function IsNoRun: Boolean;
begin
Result := ExpandConstant('{param:NORUN|0}') = '1';
end;
function IsX64: Boolean;
begin
Result := not IsArm64;
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
MarkerPath: String;
begin
if CurStep = ssPostInstall then
begin
MarkerPath := ExpandConstant('{app}\.winget');
if ExpandConstant('{param:WINGET|0}') = '1' then
SaveStringToFile(MarkerPath, '', False)
else
DeleteFile(MarkerPath);
end;
end;
"@
} else {
# Single-arch installer (backward compatible, no Check: functions needed)
if ($HasX64) {
$ArchAllowed = "x64compatible"
$StagingSource = "staging\x64\*"
} else {
$ArchAllowed = "arm64"
$StagingSource = "staging\arm64\*"
}
$IssContent = @"
#define Name "Plezy"
#define Version "$Version"
#define Publisher "edde746"
#define ExeName "plezy.exe"
[Setup]
AppId={{4213385e-f7be-4f2b-95f9-54082a28bb8f}
AppName={#Name}
AppVersion={#Version}
AppPublisher={#Publisher}
DefaultDirName={autopf}\{#Name}
DefaultGroupName={#Name}
AllowNoIcons=yes
OutputDir=.
OutputBaseFilename=plezy-windows-installer
Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=lowest
ArchitecturesAllowed=$ArchAllowed
ArchitecturesInstallIn64BitMode=$ArchAllowed
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "$StagingSource"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{group}\{#Name}"; Filename: "{app}\{#ExeName}"
Name: "{group}\{cm:UninstallProgram,{#Name}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#Name}"; Filename: "{app}\{#ExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#ExeName}"; Description: "{cm:LaunchProgram,{#Name}}"; Flags: nowait postinstall; Check: not IsNoRun
[Code]
function IsNoRun: Boolean;
begin
Result := ExpandConstant('{param:NORUN|0}') = '1';
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
MarkerPath: String;
begin
if CurStep = ssPostInstall then
begin
MarkerPath := ExpandConstant('{app}\.winget');
if ExpandConstant('{param:WINGET|0}') = '1' then
SaveStringToFile(MarkerPath, '', False)
else
DeleteFile(MarkerPath);
end;
end;
"@
}
$IssContent | Out-File -FilePath $SetupScript -Encoding ASCII
New-InnoSetupScript -Version $Version -HasX64 ([bool]$HasX64) -HasArm64 ([bool]$HasArm64) |
Out-File -FilePath $SetupScript -Encoding ASCII
Write-Host "Created: $SetupScript" -ForegroundColor Green
# Check for Inno Setup