欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_InnoSetup

我一直对「安装包」这种东西有种奇怪的兴趣。它像一个盒子:外面是一层引导程序,里面塞着一整个软件的全部家当——可执行文件、图标、字体、文档、示例数据、注册表脚本、快捷方式规则。你双击它,它就在你机器上铺开一片天地。但盒子关上之后,里面究竟装了什么,普通人永远看不见。

这次想做的事,说白了就是把盒子打开,而且是在一个完全陌生的地盘上打开——鸿蒙 PC。

Inno Setup 是 Windows 世界里最经典的打包工具之一,1997 年至今,无数软件以它发行。它的格式是开放的:外层是 SetupLdr 引导层,内层是 SetupHeader 数据段,文件清单、压缩块、甚至每个文件的内容哈希都明明白白写在里面。这意味着,只要肯花时间啃二进制,我们完全可以在鸿蒙 PC 上把它读出来——不装 Wine,不开模拟器,不执行任何一行 Windows 代码。

「包匣 · InnoForge」就是这么长出来的。它不试图把 Windows 程序跑起来(那是另一个量级的事,也是我明确划出去的边界),它只做三件事:读懂安装包、按哈希校验地把内容掏出来、再把 .iss 脚本里的应用语义映射成一份鸿蒙 HAP 工程的骨架。听起来简单,做起来坑不少——最有意思的一个是 E8/E9 指针反变换的作用域搞错了,导致「小文件全对、大文件全废」,最后靠包内自带的 SHA256 把这个 bug 揪了出来。

这篇记录里,我尽量把每个关键决策和每次踩坑都摊开写:格式怎么定位、缓存怎么省时间、文件夹选择器怎么绕开沙箱、UI 怎么从调试工具变成能看的产品,以及那个绕不过去的选择题——什么样的东西能迁,什么样的东西不该迁。

不是在鸿蒙上运行 Windows 程序,而是把 Windows 安装包的数据面完整读出来:解析二进制布局、解压 LZMA 数据流、逐文件 SHA256 校验,最后映射成鸿蒙 HAP 工程。本文记录这条链路上每一个真实的坑。

应用运行于鸿蒙 PC 桌面

一、痛点与定位

Windows 生态里有大量软件用 Inno Setup 打包发行。想把其中纯资源型的内容(文档、图片、字体、脚本、配置)迁移到鸿蒙 PC 上复用时,通常只有两条路:找一台 Windows 机器装一遍再拷文件,或者对着 Setup.exe 一筹莫展。
在这里插入图片描述

「包匣 · Inno 包工作台」(工程名 ohos_InnoForge)做的是第三条路:在鸿蒙 PC 本地,直接解析 Inno Setup 安装包的二进制格式。它不执行任何 Windows 代码,因此不存在模拟器、 Wine 之类的重型方案,也不存在安全面问题——它只是一个「格式读取器 + 语义映射器」。

产品分三段:

阶段名称职责
P0包浏览打开 Setup.exe,读取 SetupHeader / FileLocation,展示包信息与文件清单,支持逐文件/批量提取
P1脚本映射解析 .iss 脚本,把 [Setup]/[Files]/[Icons] 等段映射为鸿蒙应用清单,Windows 专属语义显式标记为不支持
P2出包把映射结果与已提取文件组装为 pack-manifest.json + payload/,在 PC 上用 hvigor 构建未签名 HAP

明确的边界:它不能把任意 Windows 软件自动变成鸿蒙应用。EXE/DLL 是 x86/PE 二进制,鸿蒙设备是 arm64,这个跨越不在本工具的射程内;它迁移的是「安装包里携带的数据资产」和「脚本声明的应用语义」。

包浏览页:137 个条目、lzma2、包信息与文件清单

二、总体架构

在这里插入图片描述

选 C++ 而不是 ArkTS 解析的三个理由:

  1. 格式里有大量指针运算和内存切片(64 位小端整数、变长字符串、块级标志位),C++ 表达最直接;
  2. LZMA 解压直接复用上游 LzmaDec.c/Lzma2Dec.c(公有领域源码), ArkTS 侧没有现成绑定;
  3. 解析器要做纯函数式隔离——不碰任何系统能力,方便在 PC 上先做端到端探针测试,再原样搬进 HAP。

三、Inno Setup 数据格式逆向笔记

Inno Setup 7.x 的自安装包是「外层 SetupLdr 引导 + 内层数据段」的两段结构。关键锚点:

锚点位置/标识作用
SetupID64 字节,形如 Inno Setup Setup Data (7.1.1)判定格式与引擎版本,非 Inno 包在此直接拒绝
偏移表标识12 字节魔数 72 44 6C 50 74 53 CD E6 D7 7B 0B 2A定位 offset table,从中取 offset0(SetupHeader)、offset1(数据区基址)
SetupHeaderoffset0 起的压缩记录应用名、版本、压缩方法、文件计数等
TSetupFileLocationEntrySetupHeader 内嵌记录每个文件的 startOffset / suboffset / compressedSize / originalSize / flags / SHA256Sum

数据区的每个压缩块以 zlb\x1A 开头(这也是 “zlib” 的变体彩蛋),其后是 LZMA1(method 3)或 LZMA2(method 4)裸流。startOffset 指向块的起点,suboffset 指向文件内容在解压后块数据中的偏移——solid 压缩时多个文件共享同一块,这是后面一个关键 bug 的伏笔。

四、解析引擎的核心代码

4.1 定位与判定

打开包的第一件事不是读,而是拒绝:

constexpr uint64_t kMaxPackage = 512ull * 1024 * 1024;   // 安装包上限 512MB
constexpr size_t   kMaxPlain   = 256ull * 1024 * 1024;   // 单块解压上限 256MB

const char kSetupIdPrefix[] = "Inno Setup Setup Data (";
const uint8_t kOffsetTableId[12] = {0x72, 0x44, 0x6C, 0x50, 0x74, 0x53,
                                    0xCD, 0xE6, 0xD7, 0x7B, 0x0B, 0x2A};
const uint8_t kZlibId[4] = {'z', 'l', 'b', 0x1A};

扫描偏移表魔数定位 offset table,读出 offset0/offset1 后解析 Setup-0 记录。所有读取都带边界检查,任何越界立即返回结构化错误(中文错误信息直接透传到 UI 状态条),而不是崩溃。加密包(encrypted 标志)被识别后直接标记不可提取,避免产出垃圾文件。

4.2 提取的正确性:E8/E9 反变换是「块级」的

这是整个项目最有价值的一个 bug,值得完整展开。

LZMA 压缩前,Inno 会对压缩块做一次 x86 call/jmp 指针变换(TSetupFileLocationEntry 的 flags & 0x04,即 floCallInstrOpt):把 64KB 分块内的相对地址改写为绝对地址,以提高 LZMA 对 PE 文件的压缩率。解压后必须逆变换还原。

第一版实现把流程写成了「解压 → 切片 → 变换」:

// 错误版本:切片后再反变换
std::vector<uint8_t> content(plain.begin() + item.suboffset,
                             plain.begin() + item.suboffset + item.size);
if ((item.flags & 0x04) != 0) {
  TransformDecode(content);   // BUG:作用域错了
}

问题在于:反变换的正确作用域是整个解压块,不是单个文件的切片。变换器按 64KB 对齐块工作,地址基准从块首开始计数:

void TransformDecode(std::vector<uint8_t>& b) {
  uint32_t addrOffset = 0;
  for (size_t off = 0; off < b.size(); off += 65536) {
    size_t n = std::min<size_t>(65536, b.size() - off);
    TransformBlock(b.data() + off, n, addrOffset);
    addrOffset += static_cast<uint32_t>(n);   // 地址基准跨 64KB 块累加
  }
}

solid 包里,第一个文件 suboffset=0 恰好等于块首,切片后变换「碰巧正确」;而从第二个文件开始,64KB 分块边界和 suboffset 不再对齐,变换后的 call/jmp 目标地址全部错乱——文本文件提取成功,EXE/DLL 提取出来必然损坏,且 SHA256 校验失败。这类「小文件全绿、大文件全红」的故障模式极具迷惑性,因为自测包往往小而简单。

修复后的正确顺序(先整块反变换,再按 suboffset 切片):

/* E8/E9 反变换作用于整个解压块(floCallInstrOpt 是块级标志),
   必须先变换再按 suboffset 切片,否则切片内容地址错乱。 */
if ((item.flags & 0x04) != 0) {
  TransformDecode(plain);
  // 反变换改变了块内容,对应缓存作废
  if (item.startOffset == cachedStart) {
    cachedPlain.clear();
    cachedStart = UINT64_MAX;
  }
}

if (item.suboffset > plain.size() || item.size > plain.size() - item.suboffset) {
  return fail("解压数据不足(solid chunk 偏移越界)");
}
std::vector<uint8_t> content(
    plain.begin() + static_cast<std::ptrdiff_t>(item.suboffset),
    plain.begin() + static_cast<std::ptrdiff_t>(item.suboffset + item.size));

4.3 solid 包的提取性能:文件块缓存

solid 压缩时一个 LZMA 块可能容纳几十个文件,逐文件提取意味着同一块被反复解压。给 Reader 加了一个单槽缓存:

// inno_reader.h
class Reader {
  // ...
private:
  // 最近一次解码的文件块缓存:solid 包中多个文件共享同一块,避免重复解压
  mutable uint64_t cachedStart = UINT64_MAX;
  mutable std::vector<uint8_t> cachedPlain;
};
bool useCache = !compressed || pkg_.compressMethod == 0 ||
                pkg_.compressMethod == 3 || pkg_.compressMethod == 4;
if (useCache && item.startOffset == cachedStart && !cachedPlain.empty()) {
  plain = cachedPlain;                       // 命中:零解压成本
} else {
  // ... LZMA1/LZMA2 解压,成功后写入缓存
  if (useCache) {
    cachedStart = item.startOffset;
    cachedPlain = plain;
  }
}

由于清单按块内顺序排列,「批量提取」时缓存几乎全程命中,一次解压服务整块的所有文件。代价是单块最大 256MB 的内存峰值,kMaxPlain 上限 + 解压失败即中断的双重保护下可控。

4.4 SHA-256 逐文件校验

每个 FileLocationEntry 自带 32 字节 SHA256Sum,提取结果必须校验后才能算成功:

uint8_t digest[32];
Sha256 sha;
sha.Update(content.data(), content.size());
sha.Final(digest);
bool shaMatch = item.sha.size() == 32 &&
                std::memcmp(digest, item.sha.data(), 32) == 0;

resultJson = "{\"ok\":true,\"path\":" + JStr(dest) +
             ",\"size\":" + std::to_string(content.size()) +
             ",\"sha256Match\":" + (shaMatch ? "true" : "false") + "}";

ArkTS 侧只认 ok && sha256Match === true 的结果,任何校验失败的文件直接落入「跳过」清单并显示原因,宁可少提取,不出坏文件。

五、去沙箱:文件夹选择器直出用户目录

第一版提取目标是应用沙箱 filesDir,文件「看得见摸不着」。鸿蒙 PC(2in1 设备)的 DocumentViewPicker 支持文件夹选择,输出直接落到用户指定的目录:

private async chooseOutDir(): Promise<boolean> {
  if (this.outDirUri.length > 0) {
    return true;                       // 会话内缓存,选一次处处可用
  }
  const opts = new picker.DocumentSelectOptions();
  opts.maxSelectNumber = 1;
  opts.selectMode = picker.DocumentSelectMode.FOLDER;   // 2in1 设备支持
  const dp = new picker.DocumentViewPicker(this.getCtx());
  const uris: string[] = await dp.select(opts);
  if (uris.length === 0) {
    return false;
  }
  this.outDirUri = uris[0];
  this.outDirPath = new fileUri.FileUri(uris[0]).path;
  return true;
}

批量提取把每个文件的结果(成功/跳过 + 原因)实时记录为 ResultRow,UI 逐行渲染 ✓/✕:

private async extractAll(): Promise<void> {
  if (this.fileRows.length === 0) { this.setStatus('请先打开安装包', false); return; }
  if (!(await this.chooseOutDir())) { this.setStatus('未选择输出目录', false); return; }
  const rows: ResultRow[] = [];
  const paths: string[] = [];
  for (const row of this.fileRows) {
    if (!row.extractable) {
      rows.push({ name: row.name, ok: false, detail: row.note });
      continue;
    }
    if (this.extractOne(row)) {
      const safeName = row.name.replace(/[\\/]/g, '_');
      paths.push(this.outDirPath + '/' + safeName);
      rows.push({ name: row.name, ok: true, detail: fmtSize(row.size) + ' · SHA256 校验通过' });
    } else {
      rows.push({ name: row.name, ok: false, detail: '提取或校验失败' });
    }
  }
  this.extractedPaths = paths;
  this.lastResults = rows;
  this.setStatus('已提取 ' + paths.length + ' 个 → ' + this.outDirPath, paths.length > 0);
}

P2 生成 HAP 工程同样复用这套目录选择:用户选定导出目录后,pack-manifest.json、payload/、BUILD.txt 直接写进用户空间,全程零沙箱残留。

选择安装包:系统文件选择器

六、UI:把「调试工具」做成「产品」

初版 UI 是纯功能堆叠。重设计围绕三个原则:

  • 信息密度分层:顶栏(品牌 + 载入状态徽标)→ 全局状态条(成功绿/警示橙,区分操作结果)→ 页签 → 卡片内容;
  • 卡片化:统一圆角 16、细边框、深色底,用 @Styles 复用避免每处重复属性链;
  • 操作闭环:每个文件行自带大小、可提取标签与单文件提取按钮,提取后有逐文件结果卡。
@Styles
fancyCard() {
  .backgroundColor(C_CARD)
  .borderRadius(16)
  .border({ width: 1, color: C_LINE })
  .padding(16)
}

一个 ArkTS 实现细节:@Builder 方法不能接收 UI 闭包(this.card(() => { Row() {...} }) 这种写法编译不过),可复用的卡片样式要用 @Styles 挂到 Column 上,内容留在原地。

包浏览页:137 个条目、lzma2、包信息与文件清单

七、端到端测试:合成包 + 探针 DLL

解析器最大的测试难点是没有标准测试语料。做法是写一个 Inno 格式合成器,构造带已知 SHA256 的合成安装包,再写一个 Windows 探针 DLL 把 inno_reader.cpp 原样编译进去,跑同一份代码验证:

{
  "ok": true,
  "report": {
    "open": { "ok": true, "setupId": "Inno Setup Setup Data (7.1.1)",
              "appName": "示例应用", "compression": "lzma2", "compressMethod": 4,
              "hasOffsetTable": true, "fileCount": 2 },
    "list": [
      { "name": "hello.txt", "size": 1000, "suboffset": 0, "extractable": true },
      { "name": "data.bin",  "size": 775,  "suboffset": 0, "extractable": true }
    ],
    "extract": [
      { "name": "hello.txt", "bytes": 1000, "sha256": "8dcc061357324e55" },
      { "name": "data.bin",  "bytes": 775,  "sha256": "d812c5c76ad9e552" }
    ],
    "negative": "未找到 Inno Setup 数据标识,可能不是 Inno Setup 安装包或启用了整包加密"
  }
}

注意 "negative":用非 Inno 文件(如任意下载的 .exe)跑负例,断言引擎给出结构化拒绝而不是误解析。正例 + 负例两头卡住,才有底气说「打不开 = 不是 Inno 包」,而不是「解析器坏了」。

真机实测用 Inno Setup 7.1.1 官方安装包(14.3MB):137 个条目全部解析成功,压缩方法 lzma2、偏移表识别、加密标志为否,与官方引擎行为一致。

八、P1 脚本映射与 P2 出包

P1 把 .iss 文本解析成结构化清单:[Setup] 的 AppName/Version/Publisher 进清单元数据,[Files] 的每条 Source/DestDir 映射为鸿蒙资源条目;[Icons]、[Registry]、[Run] 等 Windows 专属语义逐条列入 unsupported,在 UI 中显式展示而不是静默丢弃——迁移工具最忌讳「看起来都迁过去了」。

脚本映射页:左侧 .iss 脚本,右侧鸿蒙 HAP 清单

P2 组装出的工程材料是纯 JSON + 文件拷贝,真正的 hvigor 构建放在 PC 宿主端执行:

node tools/pack-hap.mjs \
  --manifest <导出目录>/hap-project/<bundleName>/pack-manifest.json \
  --out <工程目录> --build

出包页:生成 HAP 工程并落盘到用户目录

九、QA

Q1:选了一个 .exe 打不开,是应用坏了吗?
大概率不是。工具按 SetupID(Inno Setup Setup Data (x.x.x))判定格式,非 Inno Setup 打包的程序(如 Electron 应用自带的 Squirrel/NSIS 安装器)会在第一步被拒绝。这是特性:宁可明确说「不认识」,不猜格式硬解析。用 Inno Setup 官方安装包(它自身就是 Inno 打包的)测试即可。

Q2:为什么提取出来的 EXE 之前是坏的,现在好了?
就是第四章那个 E8/E9 反变换作用域 bug。凡是带 flags & 0x04 的压缩块、且文件在块内 suboffset > 0 的,旧版必然错乱。修复后先整块反变换再切片,SHA256 校验成为最终裁判。

Q3:提取会静默产出坏文件吗?
不会。每个文件独立 SHA256 校验,不匹配即标记失败跳过;批量提取的结果卡逐行显示「SHA256 校验通过」或失败原因。

Q4:提取到哪里?为什么要选目录?
默认不落地沙箱。点「全部提取」或某行「提取」时弹出系统文件夹选择器(DocumentSelectMode.FOLDER),文件直出用户目录。该能力仅 2in1(PC 形态)设备支持,SDK 注明 FolderSelection 能力限定;未选目录时操作会被拒绝并提示,不会退回沙箱静默吞掉。

Q5:加密的安装包能处理吗?
能识别、不硬解。SetupHeader 的加密标志被读出后,包信息卡显示「已加密」,所有文件标记为不可提取并注明原因——密码学上没有绕过的捷径,工具选择诚实地失败。

Q6:solid 包批量提取会不会很慢?
块缓存解决了重复解压:同一压缩块只解压一次,块内所有文件共享解压结果。这是 solid 格式的天然红利——文件在块内连续排列,批量提取的缓存命中率接近 100%。

Q7:能把我电脑上的任意 Windows 软件变成鸿蒙应用吗?
不能,也不是它的目标。x86 PE 二进制无法在 arm64 鸿蒙上直接运行,工具迁移的是数据资产与安装语义:文档、图片、字体、配置等资源文件 + .iss 里声明的应用信息。产物是可继续开发的 HAP 工程骨架,不是可运行的 Windows 程序。

Q8:大文件(超过单块容量)的提取支持吗?
Inno 对超大文件采用分块(chunked)存储,当前版本对「单文件跨多块」的场景按不可提取处理并在清单中注明,避免产出半截文件。这是后续版本明确要补的能力。

Q9:构建 HAP 时 hvigor 报 00308018 / safe-delete ETIMEDOUT?
这是特定开发环境的问题:本机 IDE 的 safe-delete 垫片通过 NODE_OPTIONS --require 注入所有 Node 子进程,hvigor 清理大目录时垫片超时导致构建挂死。解法是构建前清掉注入变量:

// _build_hap.js:构建包装器
const env = { ...process.env };
delete env.NODE_OPTIONS;                    // 摘除 safe-delete 垫片注入
delete env.CODEBUDDY_SAFE_DELETE_ENABLED;
const r = spawnSync('cmd.exe',
  ['/c', 'C:\\Program Files\\Huawei\\DevEco Studio\\tools\\hvigor\\bin\\hvigorw.bat',
   '--mode', 'module', '-p', 'module=entry@default', '-p', 'product=default',
   'assembleHap', '--no-daemon'],
  { stdio: 'inherit', env });

Q10:安装报 9568320: no signature file?
设备只认签名包。build-profile.json5 的 default product 需要显式关联 "signingConfig": "default",否则 hvigor 会同时产出 unsigned 与 signed 两个 HAP,部署时误选 unsigned 就会报这个码。认准 entry-default-signed.hap。

十、复现与边界

# 构建(DevEco CLI,Windows)
cmd /c run-build.cmd
# 产物:entry/build/default/outputs/default/entry-default-signed.hap

# 解析器 PC 端探针测试(同一份 C++ 代码)
cmd /c tools\build-probe.cmd
python tests\probe_test.py

已知边界,诚实列出:

  • 单文件跨多压缩块(chunked)暂不可提取;
  • .iss 动态语法([Code] 段、Flags: 组合语义)只做标记不做解释;
  • 文件夹选择器仅 2in1 设备可用;
  • P2 的 hvigor 构建在 PC 宿主端完成,应用内只产出工程材料。

十一、小结

这个项目最有收获的部分不是「做了多少功能」,而是把「能打开」和「打开得对」分开对待:格式逆向靠魔数与锚点定位,正确性靠 SHA256 终审,性能靠块缓存,失败靠结构化错误与负例测试兜底。E8/E9 那个 bug 教会我们一件事——在二进制格式里,「看起来成功」和「真的成功」之间,隔着一个校验和的距离。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐