Inno Setup 安装包:.NET Desktop Runtime 检测与静默安装
STM32 OTA 升级工具基于 .NET 10 构建,分发到车间机器时必须保证目标环境已具备 .NET Desktop Runtime 10.0.8 x64。让用户自己去微软官网下载 runtime 不现实,把 runtime 安装包和主程序一起塞进 Inno Setup 安装包、在安装阶段静默拉起,是更稳妥的做法。
本文记录 Deploy_Installer.iss 里的工程实践:注册表 + 文件系统双重检测、首次进入任务页自动勾选缺失组件、/install 失败回退 /repair、20 秒轮询等待、退出码 0/3010/1641 统一处理。
一、整体打包策略
OTA 工具采用框架依赖发布(FDD),不是单文件 self-contained,因此打包时整个发布目录原样搬入 {app}:
[Files]
; OTA 升级工具整个发布目录
Source: "Artifacts\OTA\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; .NET Runtime 安装包发到临时目录,安装完自动删除
Source: "Drivers\windowsdesktop-runtime-10.0.8-win-x64.exe"; DestDir: "{tmp}"; Flags: ignoreversion deleteafterinstall; Tasks: installruntime
两个关键点:
- Runtime 安装包用
deleteafterinstall:不污染{app}目录,也不残留到系统临时目录。约 80MB 的安装包仅在[Code]段执行期间存在。 - Runtime 搬运绑定
Tasks: installruntime:如果用户取消勾选,安装包根本不会解压到临时目录,避免无谓的磁盘占用。
二、可选任务与首次自动勾选
[Tasks] 段把 runtime 安装声明为可选组件,默认 unchecked:
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
Name: "installruntime"; Description: "安装 .NET Desktop Runtime 10.0.8 x64"; GroupDescription: "可选组件"; Flags: unchecked
默认不勾选是为了给用户控制权。但首次进入任务页时,如果检测到系统缺少 runtime,应自动勾选,避免用户漏装。这部分用 CurPageChanged 钩子实现:
var
OptionalTasksInitialized: Boolean;
procedure CurPageChanged(CurPageID: Integer);
var
SelectedTasks: String;
begin
if (CurPageID = wpSelectTasks) and (not OptionalTasksInitialized) then
begin
SelectedTasks := 'desktopicon';
{ 首次进入任务页时,只自动勾选缺失的可选组件,用户仍可手动调整 }
if not IsWindowsDesktopRuntimeInstalled() then
begin
SelectedTasks := SelectedTasks + ',installruntime';
end;
WizardSelectTasks(SelectedTasks);
OptionalTasksInitialized := True;
end;
end;
OptionalTasksInitialized 标志位保证只在首次进入任务页时做一次自动勾选,用户回退上一页再前进时不会被覆盖。
三、注册表 + 文件系统双重检测
Inno Setup 的 [Code] 段用 Pascal Script 编写。检测函数先扫文件系统、再扫注册表:
function IsWindowsDesktopRuntimeInstalled(): Boolean;
var
Versions: TArrayOfString;
I: Integer;
FindRec: TFindRec;
begin
Result := False;
{ 优先检查实际运行时目录 }
if FindFirst(ExpandConstant('{pf64}\dotnet\shared\Microsoft.WindowsDesktop.App\10.*'), FindRec) then
begin
try
repeat
if (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY <> 0) and
(FindRec.Name <> '.') and (FindRec.Name <> '..') then
begin
Result := True;
Exit;
end;
until not FindNext(FindRec);
finally
FindClose(FindRec);
end;
end;
{ 再检查 .NET 安装器常用注册表位置 }
if RegGetSubkeyNames(
HKLM64,
'SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App',
Versions) then
begin
for I := 0 to GetArrayLength(Versions) - 1 do
begin
if Pos('10.', Versions[I]) = 1 then
begin
Result := True;
Exit;
end;
end;
end;
end;
为什么用两套检测:
- 文件系统优先:
{pf64}\dotnet\shared\Microsoft.WindowsDesktop.App\10.*是 runtime 实际落地目录,存在即代表可用,可信度最高。 - 注册表兜底:
HKLM64\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App是微软官方安装器写入的子键列表,部分环境下文件系统扫描失败时仍能识别。
两个检测都只匹配 10. 前缀,不锁死 patch 号,未来升级到 10.0.9 / 10.1.0 时无需改脚本。HKLM64 而不是 HKLM,确保在 64 位安装模式下读的是真实 64 位注册表视图。
四、安装 + 修复双策略
.NET Runtime 安装器在某些机器上会出现 /install 返回成功但实际未写入文件的情况(多见于残留状态、组策略冲突、WMI 损坏)。解决方案是:/install 失败后用 /repair 强制重试一次。
procedure InstallOptionalPackages();
var
RuntimeInstaller: String;
RuntimeParams: String;
RuntimeInstallLogPath: String;
RuntimeRepairLogPath: String;
LogDirectory: String;
begin
RuntimeInstaller := ExpandConstant('{tmp}\windowsdesktop-runtime-10.0.8-win-x64.exe');
LogDirectory := ExpandConstant('{app}\install-logs');
ForceDirectories(LogDirectory);
RuntimeInstallLogPath := LogDirectory + '\dotnet-desktop-runtime-install.log';
RuntimeRepairLogPath := LogDirectory + '\dotnet-desktop-runtime-repair.log';
if WizardIsTaskSelected('installruntime') then
begin
if not IsWindowsDesktopRuntimeInstalled() then
begin
RuntimeParams := '/install /quiet /norestart /log "' + RuntimeInstallLogPath + '"';
RunPackageInstaller(RuntimeInstaller, RuntimeParams, '.NET Desktop Runtime 10.0.8 x64', False);
{ 安装器可能返回成功但跳过执行,再用 repair 强制修复一次 }
if not WaitForWindowsDesktopRuntimeInstalled(20) then
begin
RuntimeParams := '/repair /quiet /norestart /log "' + RuntimeRepairLogPath + '"';
RunPackageInstaller(RuntimeInstaller, RuntimeParams, '.NET Desktop Runtime 10.0.8 x64 修复', False);
if not WaitForWindowsDesktopRuntimeInstalled(20) then
begin
MsgBox(
'.NET Desktop Runtime 安装程序已执行,但安装后仍未检测到 Microsoft.WindowsDesktop.App 10.x。' #13#10 +
'安装日志:' + RuntimeInstallLogPath + #13#10 +
'修复日志:' + RuntimeRepairLogPath,
mbError, MB_OK);
end;
end;
end;
end;
end;
几个细节值得注意:
/norestart:禁止 runtime 安装器自动重启系统,把重启决策权交给 Inno Setup 主流程。/quiet:完全静默,不弹任何 UI,适合被主安装器调用。/log:每次执行都写日志到{app}\install-logs\,方便事后排查。安装和修复日志分文件存放,避免覆盖。ShowFailureMessage传False:单次RunPackageInstaller失败不立即弹窗,留给上层逻辑判断整体成败。
五、退出码处理
.NET 安装器遵循 Windows Installer 退出码约定,不能用 ResultCode = 0 一刀切:
function IsSuccessExitCode(ResultCode: Integer): Boolean;
begin
{ 0=成功,3010=成功但需要重启,1641=成功并已请求重启 }
Result := (ResultCode = 0) or (ResultCode = 3010) or (ResultCode = 1641);
end;
| 退出码 | 含义 |
|---|---|
| 0 | 安装成功 |
| 3010 | 安装成功,需要重启才能完全生效 |
| 1641 | 安装成功,已触发重启 |
3010 和 1641 都视为成功,避免误判。/norestart 已经禁止 runtime 安装器主动重启,这两个码仅表示”装好了,等你重启”。
六、RunPackageInstaller 封装
把”执行安装器、判断退出码、更新进度条状态”统一封装,主流程只关心参数和成败:
function RunPackageInstaller(FileName: String; Parameters: String;
DisplayName: String; ShowFailureMessage: Boolean): Boolean;
var
ResultCode: Integer;
begin
Result := False;
if not FileExists(FileName) then
begin
if ShowFailureMessage then
MsgBox(DisplayName + ' 安装文件不存在:' + FileName, mbError, MB_OK);
Exit;
end;
WizardForm.StatusLabel.Caption := '正在安装 ' + DisplayName + '...';
WizardForm.ProgressGauge.Style := npbstMarquee;
try
if not Exec(FileName, Parameters, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
begin
if ShowFailureMessage then
MsgBox(DisplayName + ' 启动失败。', mbError, MB_OK);
Exit;
end;
if not IsSuccessExitCode(ResultCode) then
begin
if ShowFailureMessage then
MsgBox(DisplayName + ' 安装失败,退出码:' + IntToStr(ResultCode), mbError, MB_OK);
Exit;
end;
Result := True;
finally
WizardForm.ProgressGauge.Style := npbstNormal;
end;
end;
要点:
SW_HIDE隐藏 runtime 安装器窗口,避免两个进度条同时出现。ewWaitUntilTerminated阻塞主流程,确保 runtime 装完后再继续。ProgressGauge.Style := npbstMarquee切换为滚动条模式(runtime 安装时长不可预估),完成后finally恢复正常模式。Exec是 Inno Setup 内置函数,比 ShellExecute 更适合等待子进程退出。
七、20 秒轮询等待
.NET 安装器进程退出不代表文件已落地(注册表写入有延迟),需要轮询确认:
function WaitForWindowsDesktopRuntimeInstalled(TimeoutSeconds: Integer): Boolean;
var
I: Integer;
begin
Result := IsWindowsDesktopRuntimeInstalled();
if Result then Exit;
WizardForm.StatusLabel.Caption := '正在检测 .NET Desktop Runtime 10.0.8 x64...';
for I := 1 to TimeoutSeconds do
begin
Sleep(1000);
Result := IsWindowsDesktopRuntimeInstalled();
if Result then Exit;
end;
end;
每秒轮询一次,最多 20 秒。20 秒是经验值:实测绝大多数机器在 3~5 秒内能检测到,给 20 秒留足余量。超时不直接报错,由上层决定是否走 /repair 兜底。
八、钩子挂载点
[Code] 段共用到两个钩子:
CurPageChanged:在进入任务页时自动勾选缺失组件(前文已展示)。CurStepChanged:在ssPostInstall阶段执行 runtime 安装。
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then
begin
InstallOptionalPackages();
end;
end;
放在 ssPostInstall 而不是 [Run] 段,是因为 [Run] 段无法精细控制退出码判断和修复回退逻辑。[Code] 段拿到的控制权更完整。
九、构建流程的配合
整个安装包的构建由 Build-SignedOtaRelease.ps1 串联:
dotnet publish → signtool 签名 OTA.exe → ISCC 编译 → signtool 签名安装包
dotnet publish 用 FolderProfile 发布,产物落到 Artifacts\OTA\。ISCC 编译 Deploy_Installer.iss 输出到 Output\。最后 signtool 对安装包做 Authenticode 签名,并立即验证签名状态,防止未签名产物流出发布机。
PowerShell 脚本里有一段从 .iss 文件正则解析 #define MyAppName 和 #define MyAppVersion,确保安装包文件名和脚本声明的版本一致,避免双源版本漂移。
十、踩过的坑
HKLMvsHKLM64:在 64 位安装模式下,HKLM会重定向到WOW6432Node,读不到真实 64 位 runtime 注册表。必须用HKLM64。{pf}vs{pf64}:同理,文件系统检测必须用{pf64},否则会扫到Program Files (x86)下的 32 位 runtime(虽然 .NET 10 没出 32 位版本,但写法要正确)。/install静默跳过:某些机器上 runtime 安装器看到”已存在”会直接返回 0 但不写任何东西,必须靠二次检测确认。deleteafterinstall时机:该 flag 在[Code]段ssPostInstall之后才删除,所以InstallOptionalPackages里能正常访问{tmp}\windowsdesktop-runtime-...exe。WizardSelectTasks重复调用:如果不加OptionalTasksInitialized标志位,用户回退到上一页再前进时会被重置勾选状态,体验很差。
小结
整套方案的核心思路:安装包不只是搬文件,还要负责把依赖环境装好。Inno Setup 的 [Code] 段 Pascal Script 能力足够支撑复杂逻辑——双重检测、双策略回退、轮询等待、退出码分类处理,全部可以写得干净利落。Runtime 安装包走 {tmp} + deleteafterinstall 既不污染目标目录也不残留临时文件,是处理这类”一次性依赖”的推荐模式。