diff --git a/scripts/check_windows_installer.py b/scripts/check_windows_installer.py new file mode 100755 index 00000000..f3606d06 --- /dev/null +++ b/scripts/check_windows_installer.py @@ -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") diff --git a/scripts/ci_guard_checks.sh b/scripts/ci_guard_checks.sh index 91291ad6..f8e80488 100644 --- a/scripts/ci_guard_checks.sh +++ b/scripts/ci_guard_checks.sh @@ -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 diff --git a/scripts/test_check_windows_installer.py b/scripts/test_check_windows_installer.py new file mode 100755 index 00000000..32f481f8 --- /dev/null +++ b/scripts/test_check_windows_installer.py @@ -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() diff --git a/windows/build-installer.ps1 b/windows/build-installer.ps1 index 26eb0749..b782186d 100644 --- a/windows/build-installer.ps1 +++ b/windows/build-installer.ps1 @@ -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