Windows 安装包制作
下载地址
https://www.zhengdianxue.com/wp-content/uploads/skills/org-ln4nqpas--windows-desktop-packaging.zip将提示词发送给你的 AI 安装该 skills
请根据 https://skillhub.cn/install/skillhub.md,安装 @org-ln4nqpas/windows-desktop-packaging。- 把 Windows 桌面程序打成能直接发给用户的安装程序,而不是一个"
- 编译通过就当交付"
- 的 exe。 适用场景:把 Python / Node / Electron 程序打包成 exe 或安装包
- 安装向导要中文
- 需要静默安装、静默卸载、批量部署
- 覆盖安装不能丢用户数据
- …(完整说明见下方「阅读全文」)
阅读全文 (展开查看完整说明)
概述
Windows 桌面程序打包交付(PyInstaller + Inno Setup)
把程序打成"最终用户不需要装任何环境"的安装程序。核心不是让编译通过,而是让装机体验正确:向导是中文、缺失的系统组件自动补齐、静默部署不卡死、升级不丢数据、卸载不残留。
一、推荐流水线骨架
packaging/
build.py 一键构建(品牌词检查 → 语法自检 → 清理 → PyInstaller → 校验 → ISCC → 校验 → 便携版 ZIP)
pyinstaller.spec onedir + console=False
installer.iss Inno Setup 6 脚本
languages/*.isl 自带语言文件(不要依赖本机)
redist/VC_redist.x64.exe 随包运行库(可选但 Win7 目标必备)
version_info.txt 版本资源(公司名 = 作者署名)
app.py 安装版唯一入口(双击 / 带参数 / --serve / --open-latest)build.py 必须做的事:品牌词扫描作为第一步(不合格直接中止)、py_compile 全量语法自检、清理上轮产物、编译后校验产物结构、最后打包便携版 ZIP。
二、八个致命坑位
这八条的共同点是:编译全程显示成功。如果你的验收标准是"ISCC 返回 0",八个坑一个都发现不了。
| # | 坑位 | 暴露时点 | 用户看到的后果 |
|---|---|---|---|
| 1 | datas 进了 _internal/ | 构建校验 | 误报失败,反复排查 |
| 2 | __pycache__ 被打进包 | 交付之后 | 包体膨胀、源码缓存外泄 |
| 3 | 批量删除被环境拦截 | 构建中途 | 构建中断,产物半新半旧 |
| 4 | 品牌词护栏与变量冲突 | 构建第一步 | 直接中止,无法出包 |
| 5 | 安装向导整体变英文 | 用户首次安装 | 全英文向导 |
| 6 | 静默安装弹出 MsgBox | 无人值守部署 | 进程永久卡死 |
| 7 | 静默覆盖删掉用户数据 | 升级安装 | 数据丢失,且无提示 |
| 8 | Win7 缺通用 CRT | 双击程序 | 无任何反应 |
1. PyInstaller 6.x 把 datas 放进了 _internal/
现象:构建脚本报"配置文件缺失",但打开 dist 目录,文件明明在里面。
根因:PyInstaller 6.0 起,datas 不再落到 exe 同级,统一进 _internal/。
处理:校验函数同时查两个位置,运行时路径解析按同一顺序回退。
def locate(name):
for base in (DIST_APP, os.path.join(DIST_APP, "_internal")):
p = os.path.join(base, name)
if os.path.exists(p):
return p
return None冻结运行时要先查 exe 所在目录、再回退 sys._MEIPASS —— 顺序反过来,用户改的配置和模板永远不会生效。
验证:分别列出 dist<app> 与 dist<app>_internal,config、web 应出现在后者;把 exe 同级的手工副本删掉,程序仍能正常启动。
2. datas 会把 __pycache__ 一起打进去
现象:便携版 ZIP 里出现 __pycache__/ 目录和 .pyc,包体比预期大几 MB。
根因:spec 里写 (目录, 目标) 的简写形式,会把目录原样收走,解释器缓存也在里面。
处理:spec 侧显式遍历并过滤,Inno 侧再加一道 Excludes。
def collect_dir(src, dest):
items = []
for cur, dirs, files in os.walk(src):
dirs[:] = [d for d in dirs if d != "__pycache__"]
rel = os.path.relpath(cur, src)
target = dest if rel == "." else os.path.join(dest, rel)
for f in files:
if f.endswith((".pyc", ".pyo")):
continue
items.append((os.path.join(cur, f), target))
return itemsSource: "..tools*"; DestDir: "{app}tools";
Flags: ignoreversion recursesubdirs createallsubdirs;
Excludes: "*.pyc,*.pyo,*__pycache__*"验证:用 Python 的 zipfile 遍历 ZIP 条目,__pycache__、.pyc、output/、data/四类命中数必须为 0。不要用 Add-Type 读 ZIP —— 它常被沙箱拦截。
3. 受限构建环境会拦截批量删除
现象:清理 build/ 时没有任何报错,但进程退出码非 0,留下一个删了一半的目录。
根因:受管环境(CI、沙箱、带终端防护的办公机)会注入删除保护,把shutil.rmtree / os.remove / pathlib.Path.unlink 改为移入回收站,单次批量删除超过阈值(如 50 个文件)时直接让解释器退出。
处理:不动环境策略,只调整清理方式 —— 交给系统命令,或分批删除。
import subprocess
def clean_dir(path, batch=30):
# 首选:交给系统命令,不经过脚本层的删除钩子
subprocess.run(["cmd", "/c", "rmdir", "/s", "/q", path], check=False)
# 备选:分批删除,每批不超过阈值
# for chunk in chunks(list(Path(path).rglob("*")), batch): ...先分清是脚本层还是文件系统层:在 %TEMP% 建一个 60 文件的小目录,用 Remove-Item -Recurse -Force 试删。能删掉就是脚本层拦截,不必再查 ACL 或占用句柄。
验证:删掉 build/ 后重跑构建,清理步骤退出码为 0,且目录确实不存在。
4. 品牌词护栏与构建脚本变量的冲突
现象:构建第一步就中止,提示"交付物含品牌字样",命中的却是构建脚本自己。
根因:护栏的设计目的是"交付物不带品牌痕迹",但实现上扫了整个仓库,连必须引用该前缀变量的构建脚本也扫了进去。
处理(首选):把护栏作用域收窄到交付树 —— dist/、安装包输出目录、便携版 ZIP 内容。源码与构建脚本本就不该在检查范围内,收窄后护栏反而更准确。
处理(次选):若确实要求全树扫描,就让源码里不出现完整字面量:
_PREFIX = "ABC" + "DEF" + "_" # 运行时才拼成完整前缀验证:两道都要过 —— 护栏 0 命中,且 dist 与 ZIP 里搜同一个词同样 0 命中。只验前者等于没验,真正要保证的是后者。
5. 安装向导整体变英文(最隐蔽的一个)
现象:编译日志写着 "Successful compile",装出来的向导全是英文。
根因:Inno Setup 本身不带中文。compiler:LanguagesChineseSimplified.isl 指向空文件或过期版本时,编译器只给 Warning(不是 Error),语言表回落成英文;而构建脚本若只打印输出末尾若干行,这条 Warning 就被吞掉了。
处理:三件事一起做。
- 自带语言文件:把与 ISCC 版本匹配的官方
.isl放进packaging/languages/,
用相对脚本目录的路径引用(MessagesFile: "languagesChineseSimplified.isl"),不依赖本机语言目录。
- 版本必须匹配:官方翻译按 Inno 版本分档(如
6.5.0+)。用Default.isl头部注释确认本机版本,
不要读 ISCC.exe 的 VersionInfo(常为 0.0.0.0):
Get-Content "C:Program Files (x86)Inno Setup 6Default.isl" -TotalCount 2- 编码:
.isl与官方一致即可(无 BOM 的 UTF-8);但含中文的.iss建议转成
UTF-8 with BOM 再交给 ISCC(用临时文件,编译后删除)。
同时修好可观测性:统计 Warning: 行数,输出被截断时明确提示,另加 -v/--verbose 打印全文。
验证:安装包本体是 lzma 整体压缩,明文搜不到任何字符串。要查安装后的 unins000.exe(消息表以 UTF-16LE 明文存在其中):
$b = [IO.File]::ReadAllBytes("$appunins000.exe")
[Text.Encoding]::Unicode.GetBytes("卸载状态") # 在 $b 中应能搜到中文应命中,英文回落串应不存在。只验这一处不够,向导页标题也要在安装界面上肉眼确认一次。
6. /SUPPRESSMSGBOXES 管不了 [Code] 里的 MsgBox
现象:无人值守安装/卸载卡住 —— 进程不退出,任务管理器里也没有可见窗口。
根因:[Code] 中 InitializeSetup / InitializeUninstall 里的 MsgBox在 /SILENT、/VERYSILENT 下依然弹出,只是没人点。
处理:所有 MsgBox 前加静默判断,静默时直接走安全分支。
function InitializeSetup(): Boolean;
begin
Result := True;
if WizardSilent then Exit; { 安装环境:只做判定,不弹提示 }
...
end;
function InitializeUninstall(): Boolean;
begin
Result := True;
if UninstallSilent then Exit; { 静默卸载默认保留数据,非破坏性 }
...
end;函数名不能混用:安装脚本用 WizardSilent(),卸载脚本用 UninstallSilent(),写错会在编译期报错。
验证:setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART 应在 60–90s 内退出;unins000.exe /VERYSILENT 同样;之后 Get-Process unins000 无残留。
7. 静默安装会跳过自定义向导页 → 默认值必须是"安全值"
现象:静默覆盖安装之后,用户的 data/ 目录空了。
根因:CreateInputOptionPage 这类自定义页在静默模式下不显示,NextButtonClick 不执行,状态变量保留声明时的初值。若初值是"清空数据",静默安装就等于执行了删除。
处理:在 InitializeSetup 里兜底赋默认值,不依赖向导页。
function InitializeSetup(): Boolean;
begin
Result := True;
KeepData := True; { 必须在此处兜底,不能只依赖向导页 }
...
end;验证:装一次 → 往 data/ 放一个标记文件 → 静默覆盖安装 → 标记文件仍在。再跑一次带界面的覆盖安装,确认自定义页上的选择依然能正确覆盖默认值。
8. Win7 SP1 目标必须随包 VC++ 运行库
现象:Win10/11 一切正常,Win7 SP1 双击程序毫无反应(事件查看器里是 0xc0000135)。
根因:python.org 的 Python 3.9 目录只带 vcruntime140.dll,不带通用 CRT(api-ms-win-crt-* / ucrtbase.dll)。Win10/11 系统内置,Win7 SP1 需 KB2999226,多数机器没有。
处理:先确认依赖,再决定是否随包。
$b = [Text.Encoding]::ASCII.GetString([IO.File]::ReadAllBytes("$internalpython39.dll"))
$b.Contains("api-ms-win-crt") # True → 需要通用 CRT随包 VC_redist.x64.exe(约 24 MB),注意三点:
- Win7 SP1 目标选 VS2019 版(14.29),覆盖面最广;VS2022 后期版本已收紧对 Win7 的支持。
- 检测写
HKLMSOFTWAREMicrosoftVisualStudio14.0VCRuntimesx64的InstalledDWORD,
Installed=1 即跳过 —— 不会把系统上更新的运行库降级。
[Files]里加skipifsourcedoesntexist,缺包时优雅降级而不是安装失败。
运行期判断必须查 {tmp},不能查 {src}:随包文件打进安装包内部,运行时才释放到 {tmp};{src} 是安装包自身所在目录,永远查不到。
function InstallVCRedist(): Boolean;
begin
Result := NeedVCRedist() and
FileExists(ExpandConstant('{tmp}redistVC_redist.x64.exe'));
end;验证:在未装 KB2999226 的干净 Win7 SP1 虚拟机上双击,能正常启动;装完后 Runtimesx64Installed 为 1,且重复安装不会重复执行。
三、打包前必查
- 先冻结源码,再打包。 打包不是"跑一次脚本",而是对一个确定版本取快照。
若同时有别人(或另一个 agent)在改代码,你打出来的 exe 与磁盘上的源码就不是一个东西,后续任何验收结论都失去意义。动手前先确认静止:
# 列出最近 15 分钟内被改过的项目文件(排除 dist/build/_internal/__pycache__)
$proj = "C:pathtoproject"; $now = Get-Date
Get-ChildItem -LiteralPath $proj -Recurse -File |
Where-Object { $_.LastWriteTime -gt $now.AddMinutes(-15) `
-and $_.FullName -notlike "*dist*" `
-and $_.FullName -notlike "*build*" `
-and $_.FullName -notlike "*_internal*" `
-and $_.FullName -notlike "*__pycache__*" } |
Sort-Object LastWriteTime -Descending |
Select-Object LastWriteTime, @{n='Rel';e={$_.FullName.Substring($proj.Length+1)}}输出应为空。非空就先搞清楚是谁在写,必要时让写方停下来。
- 打包后到交付前禁止再写源码。 若确实必须改,就要重走完整链路
「改 → 验收 → 重新打包」,不能"只补一行注释所以不用重打"——体积与行为都变了。
- 交付前做一次「产物 vs 源码」一致性自检:核心模块的 mtime 必须早于打包产物时间。
项目不是 git 仓库时,这是唯一的版本锚点。被改过的文件用 mtime 一条命令就能揪出来:
Get-ChildItem -LiteralPath "core" -File | Sort-Object LastWriteTime -Descending |
Select-Object -First 6 Name, LastWriteTime
Get-ChildItem -LiteralPath "installer_out" -File | Select-Object Name, LastWriteTime- 同一个输出目录不要被两方并发写。 并发跑批会让验收采样到不同时刻的产物——
实测出现过同一目录先后量到 42 / 44 / 48 三个页数,排查过程完全被浪费。
- 页数变了不等于有 bug。 先归因再下结论:
「内容增加导致的合理膨胀」(如把单行截断表改成可换行表、章节增多)与「排版参数回归」(行高 / 边距 / 分页阈值被改坏)是两件事。区分办法:把改动项逐条回退分别重测,或对比同一输入下「省略号数量」与「可比字符总数」。
- exe 同级不要有运行时残留:若曾直接跑过
dist里的 exe,output/、data/会留在里面,
再打包就把测试报告发给用户了。构建脚本里加防御性清理。
console=False的 GUI 程序带参数运行时若要看到日志,用ctypes调AllocConsole()
再 open("CONOUT$", "w");无参数走 GUI 分支,不要弹黑窗。注意 windowed 模式下 sys.stdout is None,此时 print() 是静默 no-op,不会崩。
- 入口要区分分支:无参数(GUI)/ 带参数(CLI)/
--serve --port N(只起服务不弹浏览器,便于自动化)/
--open-latest(打开最新产物)。--serve 这类"不产生副作用"的模式是做自动化验证的前提。
- 端口要从起始端口向后找空闲端口(
bind试探),不要硬占用固定端口。 - 配置写回接口要保留未知键与注释键(
_说明之类),只做局部合并 —— 否则用户改过的参数会被静默清空。
四、必须跑的验证(缺一不可)
① 品牌词护栏 → 0 命中
② py_compile 全量自检 → 0 错误
③ 打包 + 产物结构校验 → exe / _internal / config / web 齐全,_internal 内无重复 exe
④ 便携版 ZIP 内容核对 → 0 个 __pycache__/.pyc/output/data 条目(用 Python zipfile,勿用 Add-Type)
⑤ 静默安装 → 退出码 0;开始菜单快捷方式、注册表卸载项、data/output 目录权限
⑥ 运行已安装程序 → 退出码 0,全部产物类型(HTML/Excel/CSV/PDF)齐备
⑦ 静默覆盖安装 → **用户数据必须保留**(放标记文件验证)
⑧ 静默卸载 → 不超时(60-90s);程序文件清除、快捷方式与注册表清除、用户数据保留
⑨ 卸载程序消息语言 → unins000.exe 中 UTF-16 中文命中、英文回落串不存在
⑩ 产物 vs 源码一致性 → 核心模块 mtime 全部早于安装包输出目录的产物时间;期间无人写入检测卡死用 Start-Process -PassThru + $p.WaitForExit(90000),超时即判失败并Get-Process unins000 | Stop-Process -Force 收尾(否则残留进程会锁住目录)。
五、环境注意(Windows + 受限 Shell)
- 某些环境的 Bash 缺 coreutils(
dirname、tail、find均 command not found)
→ 改用 PowerShell,不要浪费时间调 Bash。
- PowerShell 中文输出乱码 → 每次开头设
$OutputEncoding=[Console]::OutputEncoding=[Text.Encoding]::UTF8 并 $env:PYTHONIOENCODING="utf-8"。
Add-Type(.NET动态编译)常被沙箱拦截 → 需要解析 ZIP/XLSX 时改用 Python。- 写文件用专用工具,不要用
Out-File -Encoding utf8(会写 BOM,导致 Python 报
SyntaxError: invalid non-printable character U+FEFF)。
- Python 3.9 的 f-string 不允许表达式内含反斜杠(如
f"{len(re.findall(rb'a', x))}")→ 先赋值给变量。 - 耗时步骤(PyInstaller + ISCC 通常 20-40s)放后台跑,别硬等超时。