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:
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Guard the elevation contract in the generated Windows Inno Setup script.
|
||||
|
||||
The installer is generated at build time by windows/build-installer.ps1, so
|
||||
there is no .iss in the tree to review. These checks pin the parts a silent
|
||||
in-app update depends on: a per-user default install that can still reach a
|
||||
machine-wide copy by relaunching itself elevated (issue #1705).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_SCRIPT = ROOT / "windows/build-installer.ps1"
|
||||
if len(sys.argv) > 2:
|
||||
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [build-installer-path]")
|
||||
SCRIPT = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_SCRIPT
|
||||
APP_GUID = "4213385e-f7be-4f2b-95f9-54082a28bb8f"
|
||||
text = SCRIPT.read_text(encoding="utf-8")
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
errors.append(message)
|
||||
|
||||
|
||||
def template() -> str:
|
||||
"""The Inno Setup script emitted by New-InnoSetupScript."""
|
||||
match = re.search(r'(?ms)^ return @"\r?\n(.*?)\r?\n"@\r?\n', text)
|
||||
require(match is not None, "New-InnoSetupScript must return a single here-string template")
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
require(
|
||||
"function New-InnoSetupScript" in text,
|
||||
"the .iss must be built by New-InnoSetupScript so every architecture shares one template",
|
||||
)
|
||||
iss = template()
|
||||
|
||||
# The script used to carry two near-identical copies of the whole .iss, one per
|
||||
# architecture shape. Anything that appears twice again has drifted apart.
|
||||
for once in (
|
||||
r"^\[Setup\]$",
|
||||
r"^\[Code\]$",
|
||||
r"^PrivilegesRequired=",
|
||||
r"^function InitializeSetup",
|
||||
):
|
||||
require(
|
||||
len(re.findall(once, text, re.MULTILINE)) == 1,
|
||||
f"{once} must match exactly one line; a second copy of the template will drift",
|
||||
)
|
||||
require(
|
||||
text.count(APP_GUID) == 1,
|
||||
"the AppId GUID must have a single source; AppId and the uninstall subkey both derive from it",
|
||||
)
|
||||
|
||||
require("AppId={{$AppGuid}" in iss, "AppId must be built from the shared $AppGuid")
|
||||
require(
|
||||
r"Uninstall\{$AppGuid}_is1" in iss,
|
||||
"the uninstall subkey must be the shared AppId with Inno's _is1 suffix",
|
||||
)
|
||||
require(
|
||||
"OutputBaseFilename=plezy-windows-installer" in iss,
|
||||
"the release asset name is referenced by the appcast, winget and the website",
|
||||
)
|
||||
require(
|
||||
"ArchitecturesAllowed=$ArchAllowed" in iss
|
||||
and "ArchitecturesInstallIn64BitMode=$ArchAllowed" in iss,
|
||||
"architectures must come from the template parameter, not be hard-coded",
|
||||
)
|
||||
require(
|
||||
"Check: IsX64" in text and "Check: IsArm64" in text,
|
||||
"the dual-architecture [Files] entries must keep their architecture checks",
|
||||
)
|
||||
|
||||
# A fresh install stays per-user and prompts for nothing; only an existing
|
||||
# machine-wide copy pulls in elevation, and only via /ALLUSERS, which Inno
|
||||
# ignores unless the commandline override is allowed.
|
||||
require(
|
||||
re.search(r"(?m)^PrivilegesRequired=lowest\s*$", iss) is not None,
|
||||
"a fresh install must stay per-user; PrivilegesRequired=lowest",
|
||||
)
|
||||
overrides = re.search(r"(?m)^PrivilegesRequiredOverridesAllowed=(.+)$", iss)
|
||||
require(
|
||||
overrides is not None and "commandline" in overrides.group(1),
|
||||
"PrivilegesRequiredOverridesAllowed must allow commandline or /ALLUSERS is inert",
|
||||
)
|
||||
require(
|
||||
overrides is None or "dialog" not in overrides.group(1),
|
||||
"allowing dialog makes a silent install with no previous copy prompt; winget installs that way",
|
||||
)
|
||||
|
||||
# The elevation path itself.
|
||||
require(
|
||||
"IsAdminInstallMode" in iss,
|
||||
"the elevation path must be skipped once Setup already runs in administrative install mode",
|
||||
)
|
||||
require(
|
||||
"{param:ELEVATED|0}" in iss,
|
||||
"the relaunched instance needs a guard parameter so it cannot elevate again",
|
||||
)
|
||||
require(
|
||||
"SaveStringToFile(Probe" in iss,
|
||||
"elevation must be driven by probing the install directory for write access",
|
||||
)
|
||||
require(
|
||||
"ShellExec('runas'" in iss and "{srcexe}" in iss,
|
||||
"a non-writable install directory must relaunch this installer elevated",
|
||||
)
|
||||
for parameter in ("/ALLUSERS", "/ELEVATED=1", "/DIR="):
|
||||
require(
|
||||
parameter in iss,
|
||||
f"the elevated relaunch must pass {parameter}",
|
||||
)
|
||||
require(
|
||||
"'/CURRENTUSER'" in iss,
|
||||
"the forwarded command line must drop /CURRENTUSER, which would undo /ALLUSERS",
|
||||
)
|
||||
require(
|
||||
"CustomMessage('ElevationRequired')" in iss
|
||||
and re.search(r"(?m)^ElevationRequired=\S", iss) is not None,
|
||||
"a refused elevation must explain itself instead of failing silently",
|
||||
)
|
||||
|
||||
# Behavior other tooling already depends on.
|
||||
require(
|
||||
"{param:WINGET|0}" in iss and "{app}\\.winget" in iss,
|
||||
"the winget marker file gates UpdateService.useNativeUpdater",
|
||||
)
|
||||
require(
|
||||
"{param:NORUN|0}" in iss and "Check: not IsNoRun" in iss,
|
||||
"the winget manifest passes /NORUN=1 and expects the launch entry to honor it",
|
||||
)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print("windows installer elevation checks passed")
|
||||
@@ -23,7 +23,8 @@ for checker in \
|
||||
scripts/check_workflow_security.py \
|
||||
scripts/check_workflow_action_pins.py \
|
||||
scripts/check_container_image_pins.py \
|
||||
scripts/check_update_packages_workflow.py; do
|
||||
scripts/check_update_packages_workflow.py \
|
||||
scripts/check_windows_installer.py; do
|
||||
python3 "$checker"
|
||||
done
|
||||
|
||||
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Behavior tests for the Windows installer elevation guard."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CHECKER = ROOT / "scripts/check_windows_installer.py"
|
||||
SCRIPT = ROOT / "windows/build-installer.ps1"
|
||||
|
||||
|
||||
class WindowsInstallerGuardTest(unittest.TestCase):
|
||||
def _run(self, script: str) -> subprocess.CompletedProcess[str]:
|
||||
with tempfile.TemporaryDirectory(prefix="plezy-windows-installer-test-") as directory:
|
||||
fixture = Path(directory) / "build-installer.ps1"
|
||||
fixture.write_text(script, encoding="utf-8")
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CHECKER), str(fixture)],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def _script(self) -> str:
|
||||
return SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
def _mutate(self, old: str, new: str) -> str:
|
||||
script = self._script().replace(old, new, 1)
|
||||
self.assertNotEqual(script, self._script(), f"fixture mutation no longer matches: {old!r}")
|
||||
return script
|
||||
|
||||
def test_current_script_passes(self) -> None:
|
||||
result = self._run(self._script())
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("elevation checks passed", result.stdout)
|
||||
|
||||
def test_missing_commandline_override_is_rejected(self) -> None:
|
||||
# Without the override /ALLUSERS is silently ignored and the relaunched
|
||||
# instance installs per-user again, which is issue #1705.
|
||||
script = self._mutate(
|
||||
"PrivilegesRequiredOverridesAllowed=commandline",
|
||||
"PrivilegesRequiredOverridesAllowed=",
|
||||
)
|
||||
|
||||
result = self._run(script)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("/ALLUSERS is inert", result.stderr)
|
||||
|
||||
def test_dialog_override_is_rejected(self) -> None:
|
||||
script = self._mutate(
|
||||
"PrivilegesRequiredOverridesAllowed=commandline",
|
||||
"PrivilegesRequiredOverridesAllowed=commandline dialog",
|
||||
)
|
||||
|
||||
result = self._run(script)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("winget installs that way", result.stderr)
|
||||
|
||||
def test_admin_default_is_rejected(self) -> None:
|
||||
script = self._mutate("PrivilegesRequired=lowest", "PrivilegesRequired=admin")
|
||||
|
||||
result = self._run(script)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("must stay per-user", result.stderr)
|
||||
|
||||
def test_dropping_the_elevated_relaunch_is_rejected(self) -> None:
|
||||
script = self._mutate(
|
||||
" if ShellExec('runas', ExpandConstant('{srcexe}'), Params, '', SW_SHOW, ewNoWait, ErrorCode) then",
|
||||
" if False then",
|
||||
)
|
||||
|
||||
result = self._run(script)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("relaunch this installer elevated", result.stderr)
|
||||
|
||||
def test_dropping_the_recursion_guard_is_rejected(self) -> None:
|
||||
script = self._mutate("/ELEVATED=1 /DIR=", "/DIR=")
|
||||
|
||||
result = self._run(script)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("/ELEVATED=1", result.stderr)
|
||||
|
||||
def test_silent_elevation_failure_is_rejected(self) -> None:
|
||||
script = self._mutate(
|
||||
" SuppressibleMsgBox(FmtMessage(CustomMessage('ElevationRequired'), [PreviousDir]),\n"
|
||||
" mbCriticalError, MB_OK, IDOK);\n",
|
||||
"",
|
||||
)
|
||||
|
||||
result = self._run(script)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("explain itself", result.stderr)
|
||||
|
||||
def test_second_template_copy_is_rejected(self) -> None:
|
||||
# The regression this guard exists for: the script used to hold one
|
||||
# whole .iss per architecture shape, and they drifted.
|
||||
script = self._script()
|
||||
marker = "[Setup]\n"
|
||||
self.assertIn(marker, script)
|
||||
script = script.replace(marker, marker + "[Setup]\n", 1)
|
||||
|
||||
result = self._run(script)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("a second copy of the template will drift", result.stderr)
|
||||
|
||||
def test_losing_the_winget_marker_is_rejected(self) -> None:
|
||||
script = self._mutate("{param:WINGET|0}", "{param:NOTWINGET|0}")
|
||||
|
||||
result = self._run(script)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("winget marker file", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+230
-145
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user