iOS打包(重签名的方式)--用Mac客户端来实现

公司最近出了个需求,要求迅速给客户打一些马甲包,就是替换里面的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"
}

这些常量在以下内容中会用到

1解包

//解压之前先创建解压目录
 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<String>)["task"] as! Process
        let complete : (Bool) -> Void = (sender.userInfo as! Dictionary<String>)["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)
        }

    }

2移除签名文件

为什么要移除签名文件,因为我们这个是二次签名,以前的签名文件肯定不能用了啊,而且如果不移除了会影响签名过程

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)
            }
        }
    }

3 编辑plist文件

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)

    }

4替换icon和launchImage

fileprivate func replaceIconAndLaunchImage(complete :@escaping (Bool) -> Void) {

       //这里每个被替换的图片的名字都是你原来工程里面配置的名字,比如AppIcon29x29@2x.png,AppIcon就是我在Xcode-General-App icons And Launch Images-App Icons Source中配置的名字,launchImage同理
        let destinationNameArray = ["AppIcon29x29@2x.png", "AppIcon29x29@3x.png",
                                    "AppIcon40x40@2x.png", "AppIcon40x40@3x.png",
                                    "AppIcon60x60@2x.png", "AppIcon60x60@3x.png",
                                    "LaunchImage-700@2x.png", "LaunchImage-700-568h@2x.png",
                                    "LaunchImage-800-667h@2x.png", "LaunchImage-800-Portrait-736h@3x.png"]
        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..<sourcePathArray xss=removed xss=removed xss=removed> 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)
    }

5编辑entitlement文件

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<String> = 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<String>
                    entitlementDic = entitlementDic["Entitlements"] as! Dictionary<String>
//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)
            }

        }

    }

6替换Provision文件

这里要注意一下,放入打包目录里面的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)
        }
    }

7重签名

// 在.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 xss=removed xss=removed xss=removed xss=removed xss=removed xss=removed xss=removed> 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的时候也遇到很多问题。此外,重签名的步骤不能乱,目前这个顺序就是经过我反复验证的了,文中出现了一些外面传进来的变量,比如record变量,它的属性名的字面意思已经再清楚不过了,这里就不再一一说明