把 Inno Setup 安装包「拆开」在鸿蒙 PC 上:包匣 · Inno 包工作台的移植实践
欢迎加入开源鸿蒙 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 工程。本文记录这条链路上每一个真实的坑。

一、痛点与定位
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,这个跨越不在本工具的射程内;它迁移的是「安装包里携带的数据资产」和「脚本声明的应用语义」。

二、总体架构

选 C++ 而不是 ArkTS 解析的三个理由:
- 格式里有大量指针运算和内存切片(64 位小端整数、变长字符串、块级标志位),C++ 表达最直接;
- LZMA 解压直接复用上游
LzmaDec.c/Lzma2Dec.c(公有领域源码), ArkTS 侧没有现成绑定; - 解析器要做纯函数式隔离——不碰任何系统能力,方便在 PC 上先做端到端探针测试,再原样搬进 HAP。
三、Inno Setup 数据格式逆向笔记
Inno Setup 7.x 的自安装包是「外层 SetupLdr 引导 + 内层数据段」的两段结构。关键锚点:
| 锚点 | 位置/标识 | 作用 |
|---|---|---|
| SetupID | 64 字节,形如 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(数据区基址) |
| SetupHeader | offset0 起的压缩记录 | 应用名、版本、压缩方法、文件计数等 |
| TSetupFileLocationEntry | SetupHeader 内嵌记录 | 每个文件的 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 上,内容留在原地。

七、端到端测试:合成包 + 探针 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 中显式展示而不是静默丢弃——迁移工具最忌讳「看起来都迁过去了」。

P2 组装出的工程材料是纯 JSON + 文件拷贝,真正的 hvigor 构建放在 PC 宿主端执行:
node tools/pack-hap.mjs \
--manifest <导出目录>/hap-project/<bundleName>/pack-manifest.json \
--out <工程目录> --build

九、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 教会我们一件事——在二进制格式里,「看起来成功」和「真的成功」之间,隔着一个校验和的距离。
更多推荐




所有评论(0)