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

两个关键点:

  1. Runtime 安装包用 deleteafterinstall:不污染 {app} 目录,也不残留到系统临时目录。约 80MB 的安装包仅在 [Code] 段执行期间存在。
  2. 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\,方便事后排查。安装和修复日志分文件存放,避免覆盖。
  • ShowFailureMessageFalse:单次 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安装成功,已触发重启

30101641 都视为成功,避免误判。/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,确保安装包文件名和脚本声明的版本一致,避免双源版本漂移。

十、踩过的坑

  • HKLM vs HKLM64:在 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 既不污染目标目录也不残留临时文件,是处理这类”一次性依赖”的推荐模式。