
简介这份 ipa 转换工具面向需要在 iOS 设备上安装非 App Store 来源应用的开发者、测试人员及进阶用户围绕 .app 等软件包重新打包为 IPA、代码签名与 Info.plist 配置等环节提供可参考的本地工具与工程材料。压缩包共 31 个文件、约 447KB含 3 个 cpp 与 4 个 h 源文件、dsp/dsw 工程配置、rc 资源脚本、plist 配置以及 png/ico 图标和 exe 可执行程序兼顾源码阅读与直接运行并附示例应用与打包相关文件便于观察 IPA 的目录组织与元数据修改方式。已有 10869 人学习下载反映该主题在 iOS 打包与测试分发场景中具有持续需求。读者可获得工具工程结构、核心实现线索和可执行程序结合源码理解软件包提取、重新打包到签名配置的链路并借助示例文件查看 IPA 内部配置与资源组织适合学习研究、内部测试及合规分发前的流程验证。1. 从 .app 到 .ipa转换工具真正处理的是目录和权限手上只有一个编译出来的.app目录或者一个.xcarchive测试机在旁边等着装这时候才会明白 ipa转换工具真正做什么。.ipa的格式门槛低得离谱把.app塞进一个叫Payload的目录打包成 zip改后缀就完事。但真正让人踩坑的是压缩过程中丢掉的软链接、可执行权限和签名封包。我见过太多次包压出来了安装器转两圈弹一句“无法安装”设备日志里躺着code signature invalid。这套工具处理的就是这段流程把 Xcode 产出的中间态转成设备认得的安装包同时保证Payload结构、Info.plist关键字段、_CodeSignature和embedded.mobileprovision四者对齐。适合三类人做内测分发的客户端开发、维护打包流水线的工程化同学、要给多台测试机批量装包的测试负责人。下面按结构、路线、重签、排错的顺序拆每一步都能直接抄。2. 拆开 .ipa 看结构Payload、Info.plist 与签名封包2.1 一个 .ipa 的本质是约定好目录名的 zip.ipa没有私有文件头就是标准 zip。约定只有两条根层级必须有且只有一个Payload/目录Payload/下必须有且只有一个.appbundle。多一层目录、Payload写成小写、或者把.app直接放在根目录安装器都会在解析阶段直接拒绝连签名检查都走不到。.app内部有几样东西是硬性的Info.plist必须在 bundle 根目录与CFBundleExecutable同名的 Mach-O 可执行文件必须在根目录名字对不上就是闪退_CodeSignature/CodeResources是签名清单记录包内每个文件的哈希embedded.mobileprovision是描述文件只有走 App Store 渠道的包才不需要它。带 Framework 的工程二进制在Frameworks/带扩展的.appex在PlugIns/。路径必需作用缺失后果Payload/是固定的根目录名安装器解析失败Payload/X.app/Info.plist是包标识、版本、设备族无法解析应用信息Payload/X.app/X是主 Mach-O 可执行文件安装成功但启动即崩Payload/X.app/_CodeSignature/是资源哈希清单签名校验失败Payload/X.app/embedded.mobileprovision非商店包必需设备白名单与权限非商店渠道无法安装Payload/X.app/Frameworks/按需动态库启动时 dyld 报错Payload/X.app/PlugIns/按需扩展组件扩展功能不可用关键认知在这里zip 容器本身不参与签名。代码签名签的是 bundle 里每个 Mach-O 和CodeResources里记录的资源哈希跟外层用什么工具、什么压缩率打包没有关系。所以只要解压出来的文件内容、权限、软链接都没变重新打包一遍不会破坏签名。这条是后面所有转换路线的基础也是判断“要不要重签”的唯一标准。2.2 用三条命令把包看穿拿到一个来源不明的包先别急着装用三条命令把它的底细摸清楚。这套检查我基本形成了条件反射比装到手机上再看现象快得多。# 1. 只看目录结构确认 Payload 层级和签名文件是否齐全 unzip -l Demo.ipa | head -n 20 unzip -l Demo.ipa | grep -E Payload/[^/]\.app/(Info\.plist|_CodeSignature/CodeResources|embedded\.mobileprovision) # 2. 解压到临时目录检查签名主体信息 rm -rf /tmp/ipa_probe mkdir -p /tmp/ipa_probe unzip -q Demo.ipa -d /tmp/ipa_probe codesign -dv --verbose4 /tmp/ipa_probe/Payload/Demo.app 21 # 3. 解出描述文件核对团队、有效期和设备白名单 security cms -D -i /tmp/ipa_probe/Payload/Demo.app/embedded.mobileprovision /tmp/profile.plist plutil -p /tmp/profile.plist | grep -E Name|TeamIdentifier|ExpirationDate|get-task-allow plutil -extract ProvisionedDevices json -o - /tmp/profile.plist | head -c 200三条命令各管一段。unzip -l回答“结构对不对”重点看Payload/demo.app/Info.plist是不是在第一层以及_CodeSignature/CodeResources是否存在——少了它要么包没签要么签名被剥过。codesign -dv回答“谁签的、签的什么”输出里Identifier必须和Info.plist的CFBundleIdentifier一致TeamIdentifier必须和描述文件里的团队一致Authority那行是证书链。security cms -D回答“这台设备能不能装”ProvisionedDevices里没有当前设备 UDID装了也白装。get-task-allow这个字段值得单独看一眼。开发证书签出来的包它是true意味着允许调试器附加分发证书签的是false。有些团队把开发包当内测包发出去结果线上崩溃日志里全是附加调试器的干扰信息排查时非常痛苦。注意这三条命令只做读取不会改动包内任何字节。检查完建议把/tmp/ipa_probe清掉避免下次误用旧解压产物。2.3 手工重建一个可安装包的最小闭环结构搞清楚了重建就是照抄。下面这段是“已有.app目录、只需要封包”的最小流程也是所有 ipa转换工具最后都绕不开的那几行。WORK/tmp/rebuild APP_NAMEDemo SRC_APP/path/to/Demo.app rm -rf $WORK mkdir -p $WORK/Payload cp -R $SRC_APP $WORK/Payload/ # 清掉 macOS 的元数据垃圾部分安装器会因此判定包结构异常 find $WORK -name .DS_Store -delete find $WORK -name __MACOSX -prune -exec rm -rf {} # 补回可执行位这几行是最容易被忽略的 chmod x $WORK/Payload/$APP_NAME.app/$APP_NAME cd $WORK zip -qry $APP_NAME.ipa Payloadzip的三个参数必须一起用-q静音、-r递归、-y保留软链接。-y是这条命令的常见问题所在——Frameworks/里的动态库经常是libFoo.dylib - libFoo.1.2.3.dylib这种软链不加-y会被展开成独立的完整文件副本包体直接翻倍而且CodeResources里的哈希对不上安装后启动即崩。chmod x那行同理。cp -R在多数情况下能保住权限位但经过跨机器传输、网盘中转、或者从 zip 里解出来再拷权限位经常被抹成644。可执行文件没有执行位签名校验能过启动一定崩日志里报的是dyld: cannot load或者直接被系统杀掉很难往权限上想。提示直接在 Finder 里右键“压缩”是最差的选择。它会额外塞__MACOSX目录、丢掉软链、把权限改成默认值三件事同时发生。命令行一行zip -qry比它可靠得多。3. 三条转换路线怎么选xcodebuild 导出、Payload 压缩、脚本重建3.1 路线一让 xcodebuild 从 .xcarchive 直接导出最规范的一条路前提是你手上有.xcarchive。Xcode 的归档产物里带着完整的构建上下文和签名材料交给xcodebuild就能直接吐出.ipa不用手工碰 Payload。CI 上跑这条路线最稳。xcodebuild -exportArchive \ -archivePath build/Demo.xcarchive \ -exportPath build/export \ -exportOptionsPlist ExportOptions.plist \ -allowProvisioningUpdates四个参数关系很直接-archivePath指向归档包-exportPath是输出目录-exportOptionsPlist决定导出渠道和签名方式-allowProvisioningUpdates允许工具在发现描述文件过期时自动向开发者账号拉取更新纯离线环境要去掉这个参数并提前把描述文件装到本机。ExportOptions.plist是这条路线里唯一需要认真写的东西?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keymethod/key stringdevelopment/string keyteamID/key stringTEAM123456/string keysigningStyle/key stringmanual/string keysigningCertificate/key stringApple Development/string keyprovisioningProfiles/key dict keycom.example.demoapp/key stringDemoApp_Dev_Profile/string /dict keystripSwiftSymbols/key true/ keycompileBitcode/key false/ keydestination/key stringexport/string /dict /plist参数逐个说。method决定渠道development出开发包设备白名单最宽ad-hoc出内测包限定注册设备enterprise出企业内部分发包app-store-connect出上架包。这套取值在不同 Xcode 版本之间改过名app-store改成app-store-connect、ad-hoc改成release-testing这类变动都发生过最省事的确认方式是直接查xcodebuild -help里当前版本列出的合法值别凭记忆写。signingStyle选manual时provisioningProfiles必须把每个 Bundle ID 和描述文件名一一对上包括扩展的 Bundle ID。选automatic则把provisioningProfiles整段删掉让工具自己去匹配但 CI 环境里自动匹配经常因为钥匙串权限或者账号会话过期而失败我一般倾向手动。stripSwiftSymbols开着能减掉一批符号体积compileBitcode现在基本都填false开了反而可能让导出卡在编译阶段。这条路线唯一的问题是对输入要求严格.xcarchive必须完整Info.plist里的ApplicationProperties段不能缺。有些团队为了省磁盘空间归档后把里面的dSYMs目录删了导出会直接失败。3.2 路线二已经有 .app 目录只做封包这是最轻的一条路.app里已经带着构建阶段的签名你只需要把它装进Payload打成 zip全程不碰签名材料。第 2.3 节那段脚本就是完整实现。判断要不要重签的标准很明确有没有改动过 bundle 内任何一个字节。只是打包签名依然有效改过Info.plist、替换过资源文件、删过PlugIns里的扩展签名立刻失效必须走第 4 章的重签流程。很多人在这里出错以为打个包就完事结果装上闪退回头查半天才发现自己顺手改了个版本号。这条路线适合什么场景比如设计同学给了一张新的启动图你替换进.app后重新封包——但这种场景其实必须重签。真正适合不重签的场景只有一种从打包机拿到的.app是完整的、只缺一层 zip 外壳。判断方法就是先跑一遍codesign --verify --deep --strict过了再封包。# 封包前先验一遍过了再打包 codesign --verify --deep --strict --verbose2 /path/to/Demo.app echo 签名有效可以只封包3.3 路线三脚本重建顺带改版本号和 Bundle ID内测经常需要同一个包在不同环境各出一份或者给不同的测试组用不同的 Bundle ID 并行安装。这时候就得改Info.plist改完必须重签。PLIST$WORK/Payload/Demo.app/Info.plist # 改 Bundle ID必须和新的描述文件里 App ID 严格对应 /usr/libexec/PlistBuddy -c Set :CFBundleIdentifier com.example.demoapp.staging $PLIST # 改版本号CFBundleVersion 是构建号CFBundleShortVersionString 是展示版本 /usr/libexec/PlistBuddy -c Set :CFBundleShortVersionString 2.4.1 $PLIST /usr/libexec/PlistBuddy -c Set :CFBundleVersion 241 $PLIST # 顺手把环境标识塞进去App 里可以直接读 /usr/libexec/PlistBuddy -c Add :AppEnv string staging $PLIST 2/dev/null \ || /usr/libexec/PlistBuddy -c Set :AppEnv staging $PLISTPlistBuddy的Set在键不存在时会报错所以第一次写入要用Add重复执行要用Set。上面那个||写法就是为了让脚本可以反复跑而不用手动清状态。CFBundleVersion必须是纯数字或者点分数字塞字母进去会导致安装器解析失败。改完 Bundle ID 有两个连带动作必须做一是把新的描述文件拷进 bundle二是整包重签。少一步都会在验证阶段被拒。Info.plist里还有两个字段经常在脚本重建时被弄丢MinimumOSVersion部分安装器读它做兼容判断缺了报“应用信息无 法解析”和UIDeviceFamily决定 iPhone/iPad 兼容矩阵缺了在 iPad 上装不上。改完用plutil -p全量打印一遍核对。3.4 三条路线怎么选路线输入是否重签适用场景主要风险xcodebuild -exportArchive.xcarchive由工具完成CI、正式内测分发ExportOptions.plist写错Payload 直接压缩完整.app不需要补一层外壳、快速验证丢软链和权限位脚本重建.app 新材料必须改 Bundle ID、多环境并行描述文件与签名不匹配选型思路其实不复杂能拿到.xcarchive就走第一条最省心只是想把构建产物变成安装包就走第二条但要先验证签名需要动包内容就必须走第三条同时把重签流程一起跑通。三条路线可以叠加使用比如 CI 上用第一条导出再补一步脚本改 Bundle ID 后重签这也是一种常见做法。4. 重签名证书、描述文件与 entitlements 三者对齐4.1 签名覆盖的是什么重签失败的案例里八成不是命令写错而是没搞清楚签名到底保护了什么。代码签名对 bundle 做了三件事对主可执行文件做 Mach-O 签名把签名信息写进二进制的LC_CODE_SIGNATURE段对 bundle 内所有资源文件计算 SHA-1/SHA-256 哈希写进_CodeSignature/CodeResources把 entitlements 嵌进签名里由系统在运行时校验。所以“改一个字节就装不上”不是夸张。改Info.plist会同时让CodeResources里的哈希失效和 entitlements 里的application-identifier对不上替换一张图片只破坏哈希验证时报resource envelope is obsolete或者直接code signature invalid。--deep这个参数经常被误用。它能一次性递归验证整个 bundle看起来很省事但用于签名时行为不够可靠它按目录顺序递归签嵌套的 Framework 和扩展可能在被签完之后又被外层的签名覆盖导致最终产物里某个组件的签名对不上。验证时用--deep没问题签名时必须手动由内向外。4.2 由内向外重签的完整命令序列顺序是死的先替换描述文件再签最内层的 Mach-O逐层向外最后签主包。IDENTITYApple Development: A Developer (TEAM123456) ENT/tmp/demo.entitlements PROFILE/path/to/new_profile.mobileprovision APP$WORK/Payload/Demo.app # 1. 先换描述文件它属于包内资源必须在签名前就位 cp $PROFILE $APP/embedded.mobileprovision # 2. 签 Frameworks 下的每个动态库 find $APP/Frameworks -maxdepth 1 -name *.framework -type d 2/dev/null | while read -r fw; do codesign --force --sign $IDENTITY --timestampnone $fw done # 3. 签 PlugIns 下的扩展扩展的 entitlements 和主包不同要单独准备 find $APP/PlugIns -maxdepth 1 -name *.appex -type d 2/dev/null | while read -r ext; do codesign --force --sign $IDENTITY --timestampnone \ --entitlements /tmp/$(basename $ext .appex).entitlements $ext done # 4. 最后签主包 codesign --force --sign $IDENTITY --timestampnone --entitlements $ENT $APP # 5. 验证这里才用 --deep codesign --verify --deep --strict --verbose2 $APP--force表示覆盖已有签名重签场景必带。--timestampnone是给开发/内测签名用的避免每次都要连时间戳服务器导致离线环境卡住走正式分发渠道时才需要带时间戳。--entitlements指定权限文件这是最容易出错的一环下一节单独说。扩展那一步的 entitlements 绝不能和主包共用一份。主包可能带aps-environment推送权限扩展带的是com.apple.security.application-groups之类的共享权限混用会导致扩展加载失败表现为“主 App 正常但分享面板里看不到自家扩展”。签完之后codesign --verify --deep --strict必须过。如果输出里有invalid signature或者code object is not signed at all说明某一层漏签了用codesign -dv --verbose4逐个组件查。4.3 entitlements 从哪来怎么核对重签时不应该凭空写 entitlements 文件正确做法是从原包里 dump 出来再改。# 从已签名的包里导出原始 entitlements codesign -d --entitlements :- $APP /tmp/orig.entitlements 2/dev/null plutil -p /tmp/orig.entitlements # 从描述文件里读它允许的权限两边对比 security cms -D -i $APP/embedded.mobileprovision /tmp/profile.plist plutil -extract Entitlements json -o - /tmp/profile.plist输出的两份东西必须能对上。描述文件里的Entitlements是一份“白名单”签名时写进二进制的 entitlements 是“申请单”申请单里出现白名单没有的键安装器直接拒绝。典型的报错是MismatchedApplicationIdentifierEntitlement和entitlements have an invalid value。最核心的两个键是application-identifier和com.apple.developer.team-identifier。前者格式固定为TEAMID.bundleid必须和Info.plist里的CFBundleIdentifier完全一致后者是十位团队 ID。改过 Bundle ID 的包这两个键必须同步改否则一定失败。还有一个容易被忽略的get-task-allow。开发描述文件给的是true分发描述文件给的是false。如果 entitlements 里写了true但用的是分发描述文件验证阶段就会被拒反过来则会失去调试能力。4.4 配套关系自查表检查项位置 A位置 B不一致的报错Bundle IDInfo.plist的CFBundleIdentifier描述文件的 App IDMismatchedApplicationIdentifierEntitlement团队 ID签名证书的 OU 字段描述文件的TeamIdentifierApplicationVerificationFailed权限键二进制里的 entitlements描述文件的Entitlementsentitlements have an invalid value设备当前设备 UDID描述文件的ProvisionedDevices0xE8008015有效期当前系统时间描述文件的ExpirationDateprofile has expired这套对照建议做成脚本每次出包后自动跑一遍。人工核对五个字段出三次包就会漏一次。5. 安装失败排查五类现象对应的日志与修法5.1 现象一安装器报 ApplicationVerificationFailed现象很直观设备上弹出“无法安装应用”用命令行工具装的话返回错误码0xE8008015。这个码基本等于“验证阶段被拒”问题一定在描述文件和设备的关系上。原因通常有三种当前设备 UDID 不在ProvisionedDevices里描述文件已过期签名用的证书和描述文件根本不是一对。第三种最常见于团队里多人协作A 导出的描述文件配上 B 的证书两边都对但就是不匹配。解决办法是先按 2.2 的命令把ProvisionedDevices和ExpirationDate打出来逐个对确认设备在列、时间没过期之后用security find-identity -v -p codesigning列出本机可用的签名身份确认IDENTITY字符串里的团队 ID 和描述文件一致。这条命令输出的是完整身份名直接复制到codesign --sign后面最保险。5.2 现象二装上了点开闪退比第一种更隐蔽。安装过程一切正常图标也出来了点一下白屏然后退回桌面。这种问题的日志不在安装器里在设备系统日志里。原因排前三的是可执行位丢了、软链接被展开、改过文件但没重签。三者都会导致签名校验失败系统在启动瞬间直接杀掉进程。抓日志的方式是在 Mac 上开控制台看设备日志或者用idevicesyslog这类工具持续输出过滤amfid和code signature关键字能看到invalid code signature或者Unsatisfied entitlements这样的原文。解决就是回到 2.3 和 4.2用zip -y重新打包、由内向外重新签名两步一起重做。只重签不修权限是没用的签名对了一个没有执行位的文件系统照样不认。5.3 现象三有的设备能装有的不能同一份包A 同学的手机装得上B 同学的装不上。这种“玄学”现象其实一点都不玄就是设备白名单问题。原因很明确ad-hoc和development渠道的描述文件里写死了设备 UDID 列表。新加入的测试机没有重新注册进描述文件自然装不上。还有一种情况是新设备注册了但描述文件没重新生成本地缓存里还是旧的那份。解决方式是把新设备 UDID 加进开发者账号的设备列表重新生成描述文件重新导出包。CI 上跑的话-allowProvisioningUpdates能省掉一部分手工操作但前提是机器上有有效的账号会话。我一般会在出包脚本里加一句把ProvisionedDevices数量打印出来和测试群人数对一下少一个就停下来。5.4 现象四App 装上了但推送收不到、网络全挂功能层面出问题往往比装不上更难查。表现是 App 能启动但所有网络请求返回失败或者推送注册拿不到 token。原因是 entitlements 和描述文件的权限没对齐。最常见的是重签时用了自己手写的一份 entitlements里面漏了aps-environment或者com.apple.developer.associated-domains少了几个域名。系统在运行时会按签名里的权限做沙箱限制缺了就静默失败。解决办法是按 4.3 的方法从原包 dump 原始 entitlements只改必要的键其余原样保留。改完用plutil -p和描述文件的Entitlements逐键对比少一个键就补上。5.5 现象五包体突然翻倍这个现象不影响安装但很影响分发。一个原本 80 MB 的包变成 160 MB多半是动态库被展开了。原因就是zip少了-y。Frameworks/里的 dylib 软链被当成真实文件递归打包一个库变成两三份。用unzip -l看清单同一个库名出现多次基本就能确定。解决办法是加上-y重打然后核对包体是否回到正常范围。顺手把打包脚本里的zip命令固定成zip -qry这一串别名避免以后有人手滑。注意以上五类现象都建立在“包是你自己构建或有权测试的产物”这个前提下。拿别人的包来做转换和重签即使技术上行得通也不在正常工程实践的范围内。6. 把它接进流水线一个校验脚本和三个收尾习惯出包脚本写到能跑通只是及格线。真正省时间的是把校验也脚本化——每次出包后自动跑一遍失败就拦住别让有问题的包流到测试手上。下面这个脚本我用了很久逻辑简单但拦下过不少事故。#!/bin/bash set -euo pipefail IPA$1 DEVICE_UDID${2:-} TMP$(mktemp -d) trap rm -rf $TMP EXIT unzip -q $IPA -d $TMP APP$(find $TMP/Payload -maxdepth 1 -name *.app | head -n 1) [ -n $APP ] || { echo FAIL: Payload 下没有 .app; exit 1; } # 1. 签名完整性 codesign --verify --deep --strict $APP 2/dev/null \ || { echo FAIL: 签名校验不通过; exit 1; } # 2. Bundle ID 与 entitlements 是否一致 BID$(/usr/libexec/PlistBuddy -c Print :CFBundleIdentifier $APP/Info.plist) AID$(codesign -d --entitlements :- $APP 2/dev/null \ | plutil -extract application-identifier raw -o - - 2/dev/null || echo ) case $AID in *.$BID) ;; *) echo FAIL: Bundle ID 与 entitlements 不匹配 ($BID vs $AID); exit 1 ;; esac # 3. 设备白名单与有效期 PROF$APP/embedded.mobileprovision if [ -f $PROF ]; then security cms -D -i $PROF $TMP/profile.plist EXP$(plutil -extract ExpirationDate raw -o - $TMP/profile.plist) [ $(date -j -f %Y-%m-%dT%H:%M:%SZ $EXP %s) -gt $(date %s) ] \ || { echo FAIL: 描述文件已过期 ($EXP); exit 1; } if [ -n $DEVICE_UDID ]; then plutil -extract ProvisionedDevices json -o - $TMP/profile.plist \ | grep -q $DEVICE_UDID \ || { echo FAIL: 设备 $DEVICE_UDID 不在白名单; exit 1; } fi fi echo PASS: $IPA三个检查点对应前面三类高频故障签名完整性挡住打包丢权限和漏签Bundle ID 与application-identifier的比对挡住改包后忘记重签描述文件检查挡住过期和设备漏注册。第二个参数传设备 UDID 时会额外做白名单校验发给具体某台测试机之前跑一次能省掉一轮“装不上—重发”的来回。参数上有一点要注意plutil -extract在键不存在时会返回非零脚本里加了|| echo 兜底否则set -e会让整个脚本在扩展包这类没有application-identifier的场景下直接退出。生产环境用的话建议把这段改成显式的if判断比||更好读。三个收尾习惯都是踩过坑之后养成的。第一ExportOptions.plist一定要进版本库并且按环境拆成dev.plist、staging.plist、release.plist三份别在命令行里临时拼参数——临时参数不会有代码评审出错就是线上事故。第二每次导出后先跑校验脚本再发出去哪怕是给隔壁工位的人装一下也走这个流程因为“就改了一个小地方”的包最容易出问题。第三重签前先codesign -d --entitlements :-备份原始权限改坏了能立刻回滚这份备份比任何事后排查都值钱。我自己有过一次很蠢的经历为了赶一个演示手工改了Info.plist里的版本号然后直接压缩发出去没重签现场五台设备全装不上站在台上只能讲 PPT。从那以后我每次出包都强制走一遍这个校验脚本宁可多花三十秒也不要在别人面前打开设备日志找原因。这套转换加校验的流程希望帮到你。本文还有配套的精品资源点击获取