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
+143
View File
@@ -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")