公司最近出了个需求,要求迅速给客户打一些马甲包,就是替换里面的plist和一些资源文件(icon和launchImage),于是找了很多资料,发现这一部分很多内容都已过期或者说讲的不全面,遂收集了一个全套的ipa重签名内容,分享给大家。代码是用swift写的,版本3.0
struct PathDefine {
static let UnzipPath = NSTemporaryDirectory().appending("unzip")
static let TargetPath = "/Users/apple/Desktop"
static let outputPath : String = "/Users/apple/Desktop/autoPackage"
}
struct OtherStringDefine {
static let appid = "application-identifier"
static let kTeamIdentifier = "com.apple.developer.team-identifier"
static let kKeychainAccessGroups = "keychain-access-groups"
static let codeSignature = "_CodeSignature"
static let dbVersionKey = "dbVersion"
}
这些常量在以下内容中会用到
//解压之前先创建解压目录
fileprivate func createWorkingPath() {
let manager = FileManager.default
if manager.fileExists(atPath: PathDefine.UnzipPath) == false {
//不存在目录时创建一个
do {
try manager.createDirectory(atPath: PathDefine.UnzipPath, withIntermediateDirectories: true, attributes: nil)
} catch {
print(error.localizedDescription)
}
} else {
// 存在目录时清空目录
do {
try manager.removeItem(atPath: PathDefine.UnzipPath)
} catch {
print(error.localizedDescription)
}
//再创建一个
do {
try manager.createDirectory(atPath: PathDefine.UnzipPath, withIntermediateDirectories: true, attributes: nil)
} catch {
print(error.localizedDescription)
}
}
}
//这两个方法也是会多次被调用的,作用是监听task运行结果
fileprivate func checkComplete(task : Process, complete : @escaping (Bool) -> Void) {
Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(timerComplete(sender:)), userInfo: ["task" : task, "complete" : complete], repeats: true)
}
@objc private func timerComplete(sender : Timer) {
let task : Process = (sender.userInfo as! Dictionary)["task"] as! Process
let complete : (Bool) -> Void = (sender.userInfo as! Dictionary)["complete"] as! ((Bool) -> Void)
if task.isRunning == false {
sender.invalidate()
if task.terminationStatus == 1 {
complete(false)
} else {
complete(true)
}
}
}
//开始解包
fileprivate func unzip(complete :@escaping (Bool) -> Void) {
let task = Process.init()
task.launchPath = "/usr/bin/unzip"
//这个record.ipaInputPath是我模型中的变量,ipa母包的绝对路径 大家可以把ipa丢到终端里面,就可以看到了
task.arguments = [ "-q", record.ipaInputPath, "-d", PathDefine.UnzipPath]
let pi = Pipe.init()
task.standardOutput = pi
let file = pi.fileHandleForReading
task.launch()
let data = file.readDataToEndOfFile()
print(String.init(data: data, encoding: .utf8) ?? "")
self.checkComplete(task: task) { [unowned self] (result) in
self.removeCodeSignature() //移除签名,内容在下面
complete(result)
}
}
为什么要移除签名文件,因为我们这个是二次签名,以前的签名文件肯定不能用了啊,而且如果不移除了会影响签名过程
fileprivate func removeCodeSignature() {
var appName = self.record.ipaInputPath
appName = appName.substring(to: appName.index(appName.endIndex, offsetBy: -4))
//这里要说一下,解包后的目录Payload/下.app文件的文件名默认是你xcode里面被打包那个target的名字。因为我的工程名和target名不一样,所有要替换一下。 targetName变量就是我的target名字
if targetName.characters.count > 0 {
appName = targetName
}
let appPath = PathDefine.UnzipPath + "/Payload/" + URL.init(fileURLWithPath: appName).lastPathComponent + ".app"
let codeSignPath = appPath + "/" + OtherStringDefine.codeSignature
let manager = FileManager.default
if manager.fileExists(atPath: codeSignPath) {
do {
try manager.removeItem(atPath: codeSignPath)
} catch {
print(error.localizedDescription)
}
}
}
fileprivate func editPlist(complete : (Bool) -> Void) {
var appName = record.ipaInputPath
appName = appName.substring(to: appName.index(appName.endIndex, offsetBy: -4))
if targetName.characters.count > 0 {
appName = targetName
}
let plistPath = PathDefine.UnzipPath + "/Payload/" + URL.init(fileURLWithPath: appName).lastPathComponent + ".app/Info.plist"
let plistDic = NSMutableDictionary.init(contentsOfFile: plistPath)
if plistDic == nil {
complete(false)
return
}
//定义了一个delegate变量,因为我要打多个不同项目的包,所以他的delegate来实现不同的编辑逻辑,如果不知道的plist文件中每个key的名字,可以在这里把plistDic打印到控制台上
self.delegate?.editPlist(plistDic: plistDic!)
//移除之前的plist文件
let manager = FileManager.default
do {
try manager.removeItem(atPath: plistPath)
} catch {
print(error.localizedDescription)
}
//把plist文件按照XML格式重新写入到目录下
do {
let xmlData = try PropertyListSerialization.data(fromPropertyList: plistDic ?? "", format: .xml, options: 0) as NSData
if xmlData.write(toFile: plistPath, atomically: true) == false {
complete(false)
return
}
} catch {
print(error.localizedDescription)
}
complete(true)
}
fileprivate func replaceIconAndLaunchImage(complete :@escaping (Bool) -> Void) {
//这里每个被替换的图片的名字都是你原来工程里面配置的名字,比如[email protected],AppIcon就是我在Xcode-General-App icons And Launch Images-App Icons Source中配置的名字,launchImage同理
let destinationNameArray = ["[email protected]", "[email protected]",
"[email protected]", "[email protected]",
"[email protected]", "[email protected]",
"[email protected]", "[email protected]",
"[email protected]", "[email protected]"]
let sourcePathArray = [record.icon58Path,
record.icon87Path,
record.icon80Path,
record.icon120Path,
record.icon120_2Path,
record.icon180Path,
record.launch4Path,
record.launch5Path,
record.launch6Path,
record.launch6pPath]
for index in 0..default
var appName = record.ipaInputPath
appName = appName.substring(to: appName.index(appName.endIndex, offsetBy: -4))
if targetName.characters.count > 0 {
appName = targetName
}
let payloadPath = PathDefine.UnzipPath + "/Payload/" + URL.init(fileURLWithPath: appName).lastPathComponent + ".app"
let destinationPath = payloadPath + "/" + destinationNameArray[index]
if manager.fileExists(atPath: destinationPath) {
do {
try manager.removeItem(atPath: destinationPath)
} catch {
complete(false)
print(error.localizedDescription)
}
do {
try manager.copyItem(atPath: sourcePathArray[index], toPath: destinationPath)
} catch {
complete(false)
print(error.localizedDescription)
}
}
}
complete(true)
}
entitlement文件文件里面存放了和签名有关的内容,是通过当前这个provisionFile文件和plist中的相关内容生成的
private func createEntitlements(_ complete: @escaping (Bool) -> Void) {
//删除entitlement文件
let entitlmentPath = PathDefine.UnzipPath + "/Entitlements.plist"
let manager = FileManager.default
if manager.fileExists(atPath: entitlmentPath) {
do {
try manager.removeItem(atPath: entitlmentPath)
} catch {
print(error.localizedDescription)
}
}
let task = Process.init()
task.launchPath = "/usr/bin/security"
//record.provisionPath是我模型中ProvisonFile的路径,你可以替换你的ProvisionFile的绝对路径。但是,但是,但是,你plist中Bundle Identifier的值必须和你ProvisionFile指定的id相同,比如你ProvisionFile指定的id是com.366EC.test,那么你的plist中Bundle Identifier也必须是这个值,否则通不过security过程。另外ProvisionFile类型最好是发布类型,开发类型的我没测试过
task.arguments = ["cms", "-D", "-i", record.provisionPath]
task.currentDirectoryPath = PathDefine.UnzipPath
let pi = Pipe.init()
task.standardOutput = pi
let file = pi.fileHandleForReading
task.launch()
self.checkComplete(task: task) { [unowned self] (result) in
if result == false {
let errorString = String.init(data: file.readDataToEndOfFile(), encoding: .utf8) ?? ""
complete(false)
} else {
//这个地方会得到一个security过的字符串,但是在Mac OS 10.10以上会报出“SecPolicySetValue”打头的一句话,这个必须去掉。此时调试台也会打印“security: SecPolicySetValue: One or more parameters passed to a function were not valid.”那是正常的,不管它
let data = file.readDataToEndOfFile()
var entitlementsResult = String.init(data: data, encoding: .ascii) ?? ""
if entitlementsResult.contains("SecPolicySetValue") {
var inOutput : Array = entitlementsResult.components(separatedBy: "\n")
inOutput.remove(at: 0)
entitlementsResult = inOutput.joined(separator: "\n")
}
let entitlementData = entitlementsResult.data(using: .utf8)!
do {
var entitlementDic = try PropertyListSerialization.propertyList(from: entitlementData, options: PropertyListSerialization.ReadOptions.mutableContainers, format: nil) as! Dictionary
entitlementDic = entitlementDic["Entitlements"] as! Dictionary
//entitlementDic中有一个key为OtherStringDefine.appid的字段必须改为"teamid.bundlId"这种格式,teamID就是你证书后面括号中那一串,没有括号的去你的开发者帐号里面查,每个证书都有一个teamID
entitlementDic.updateValue(String.init(format: "%@.%@", self.record.teamId, self.record.bid), forKey: OtherStringDefine.appid)
//移除无用的字段
entitlementDic.removeValue(forKey: OtherStringDefine.kTeamIdentifier)
entitlementDic.removeValue(forKey: OtherStringDefine.kKeychainAccessGroups)
//和plist文件一样,转换为XML文件重新写入原来的目录中
do {
let xmlData = try PropertyListSerialization.data(fromPropertyList: entitlementDic, format: .xml, options: 0) as NSData
if xmlData.write(toFile: entitlmentPath, atomically: true) == false {
complete(false)
return
}
} catch {
print(error.localizedDescription)
}
} catch {
print(error.localizedDescription)
}
complete(true)
}
}
}
这里要注意一下,放入打包目录里面的provision文件的名字必须是embedded.mobileprovision
private func editProvisionFile(_ complete: @escaping (Bool) -> Void) {
//替换provisioning
let manager = FileManager.default
var appName = record.ipaInputPath
appName = appName.substring(to: appName.index(appName.endIndex, offsetBy: -4))
if targetName.characters.count > 0 {
appName = targetName
}
let payloadPath = PathDefine.UnzipPath + "/Payload/" + URL.init(fileURLWithPath: appName).lastPathComponent + ".app"
let appProfilePath = payloadPath + "/embedded.mobileprovision"
if manager.fileExists(atPath: appProfilePath) {
do {
try manager.removeItem(atPath: appProfilePath)
} catch {
print(error.localizedDescription)
}
}
//这里用了Process来做拷贝,其实用FileManager也是一样的,我之前遇到一些问题,总以为是因为使用FileManager造成的权限问题,但最后通过对比实验,发现不是这里造成的
let task = Process.init()
task.launchPath = "/bin/cp"
task.arguments = [record.provisionPath, appProfilePath]
let pi = Pipe.init()
task.standardOutput = pi
let file = pi.fileHandleForReading
task.launch()
let data = file.readDataToEndOfFile()
print(String.init(data: data, encoding: .utf8) ?? "")
self.checkComplete(task: task) { (result) in
complete(result)
}
}
// 在.app目录会有一些图片缓存,必须清理一下,否则不能通过签名
private func removeUnrealizePath(_ complete: @escaping (Bool) -> Void) {
let task = Process.init()
task.launchPath = "/usr/bin/xattr"
var appName = record.ipaInputPath
appName = appName.substring(to: appName.index(appName.endIndex, offsetBy: -4))
if targetName.characters.count > 0 {
appName = targetName
}
let appPath = PathDefine.UnzipPath + "/Payload/" + URL.init(fileURLWithPath: appName).lastPathComponent + ".app"
task.arguments = [ "-cr", appPath]
// task.currentDirectoryPath = appPath
let pi = Pipe.init()
task.standardOutput = pi
let file = pi.fileHandleForReading
task.launch()
let data = file.readDataToEndOfFile()
// print(String.init(data: data, encoding: .utf8))
self.checkComplete(task: task) { (result) in
complete(result)
}
}
private func doCodeSign(_ complete: @escaping (Bool) -> Void) {
self.removeUnrealizePath { [unowned self] (result) in
var appName = self.record.ipaInputPath
appName = appName.substring(to: appName.index(appName.endIndex, offsetBy: -4))
if self.targetName.characters.count > 0 {
appName = self.targetName
}
let appPath = PathDefine.UnzipPath + "/Payload/" + URL.init(fileURLWithPath: appName).lastPathComponent + ".app"
var task = Process.init()
task.launchPath = "/bin/sh"
task.currentDirectoryPath = PathDefine.UnzipPath
//这里是在解压目录下寻找需要加签的文件,并放入directories.txt中。这里用;\n来分隔连续执行的命令,用\n来分隔一条命令中需要换行的部分
var cmdString = "find -d " + PathDefine.UnzipPath + " \\( -name \"*.app\" -o -name \"*.appex\" -o -name \"*.framework\" -o -name \"*.dylib\" \\) > directories.txt;\n"
//把刚才那个directories.txt中得到的文件名字取出来,依次签名。self.record.certificate是你证书的名字,不是证书路径,在钥匙串里面选中证书后点显示简介就能看到
cmdString = cmdString.appendingFormat("while IFS='' read -r line || [[ -n \"$line\" ]]; do \n /usr/bin/codesign --continue -f -s \"%@\" --entitlements \"Entitlements.plist\" \"$line\" \n done < directories.txt", self.record.certificate)
//第一个参数"-c"的意思是说后面整个字符串都是命令
task.arguments = ["-c", cmdString]
let pi = Pipe.init()
task.standardOutput = pi
let file = pi.fileHandleForReading
task.launch()
let data = file.readDataToEndOfFile()
self.checkComplete(task: task) { [unowned self] (result) in
if result == false {
let errorString = String.init(data: file.readDataToEndOfFile(), encoding: .utf8) ?? ""
complete(result)
} else {
complete(result)
}
}
}
}
在这个过程中会遇到你证书冲突的情况,你看看你钥匙串里面在“登录”或者“系统”里面是不是有相同的证书,删掉同名的就行了,另外如果证书过期了也会造成签名失败。如果没有执行removeUnrealizePath()也会出现提示你证书冲突。
我之前看过一些教程,仅对.app文件进行了签名,但是里面的动态库、framework、pluging都没有加签,造成了虽然签名成功,但是无法安装的情况
fileprivate func zipAction(_ complete : @escaping (Bool) -> Void) {
let task = Process.init()
let ipaOutputPath = PathDefine.outputPath + "/" + record.appName + ".ipa"
task.launchPath = "/usr/bin/zip"
task.arguments = ["-qry", ipaOutputPath, "Payload/"]
task.currentDirectoryPath = PathDefine.UnzipPath
let pi = Pipe.init()
task.standardOutput = pi
let file = pi.fileHandleForReading
task.launch()
let data = file.readDataToEndOfFile()
print(String.init(data: data, encoding: .utf8) ?? "")
self.checkComplete(task: task) { [unowned self] (result) in
if result == false {
complete(false)
} else {
//这里就是把解包的目录删了
self.clear({ (result) in
if result == false {
print("清理缓存失败")
}
complete(true)
})
}
}
}
如果所有的命令都执行完了,并且都成功了,那么可把find调出来并打开输出的目录
NSWorkspace.shared().openFile(PathDefine.outputPath, withApplication: "Finder")
写在最后,整理这个重新打包的教程花了我一个周的时间,其中有的时间花在了制作Mac客户端的界面和学习shell命令上,而且在使用的Process的时候也遇到很多问题,希望广大coder在参考我代码时候一定要小心。文中出现了一些外面传进来的变量,比如record变量,它的属性名的字面意思已经再清楚不过了,这里就不再一一说明