├── .gitattributes ├── .gitignore ├── .swiftformat ├── CHANGELOG.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Notarize │ └── main.swift └── NotarizeKit │ ├── ErrorCodes.swift │ ├── Sh.swift │ ├── Tokenizer.swift │ └── Upload.swift ├── Tests ├── LinuxMain.swift └── NotarizeTests │ ├── ShTests.swift │ ├── Tests.swift │ ├── UploadTests.swift │ └── XCTestManifests.swift ├── package.xcconfig └── scripts └── install.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /*.xcodeproj -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --exclude .build 2 | --disable sortedImports,blankLinesAtStartOfScope,blankLinesAtEndOfScope,unusedArguments,hoistPatternLet,numberFormatting,redundantRawValues,andOperator 3 | --ifdef noindent 4 | --ranges nospace 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Version 1.0.0 2 | 3 | Initial release. 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Morten Nielsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Rainbow", 6 | "repositoryURL": "https://github.com/onevcat/Rainbow", 7 | "state": { 8 | "branch": null, 9 | "revision": "797a68d0a642609424b08f11eb56974a54d5f6e2", 10 | "version": "3.1.4" 11 | } 12 | }, 13 | { 14 | "package": "SWXMLHash", 15 | "repositoryURL": "https://github.com/drmohundro/SWXMLHash.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "9ba116841126f6c63435beef21a4cd247c32d2e7", 19 | "version": "4.7.6" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Notarize", 7 | products: [ 8 | .executable(name: "notarize", targets: ["Notarize"]), 9 | .library(name: "NotarizeKit", targets: ["NotarizeKit"]), 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/onevcat/Rainbow", from: "3.0.0"), 13 | .package(url: "https://github.com/drmohundro/SWXMLHash.git", from: "4.7.0"), 14 | ], 15 | targets: [ 16 | .target(name: "Notarize", dependencies: [ 17 | "NotarizeKit", 18 | "Rainbow", 19 | ]), 20 | .target(name: "NotarizeKit", dependencies: [ 21 | "Rainbow", 22 | "SWXMLHash", 23 | ]), 24 | .testTarget(name: "NotarizeTests", dependencies: [ 25 | "NotarizeKit", 26 | "Rainbow", 27 | "SWXMLHash", 28 | ]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Notarize

2 |

Command line tool to easily notarize a Mac app

3 |

4 | latest 5 | swift5 6 | platform 7 | license 8 |

9 |

10 | Apple recently added a new service where they will check and approve an app to not execute malcious code. It is a great security improvement, but the downside is that this adds a new step in the build system. 11 | 12 | This project is created to make it easy to automate this process. 13 | 14 | ## Installation 15 | 1. Download the [latest release](https://github.com/Mortennn/Notarize/releases/latest) 16 | 2. Run the install script: `$ ./install.sh` 17 | 18 | ## Usage 19 | 20 | ### Example 21 | ``` 22 | $ notarize \ 23 |     --package "~/path/to/app.dmg" \ 24 |     --username "mail@icloud.com" \ 25 |     --password "@keychain:AC_PASSWORD" \ 26 |     --primary-bundle-id "com.company.appname.dmg" 27 | ``` 28 | 29 | ### Description 30 | ``` 31 | $ notarize --help 32 | 33 | Copyright (c) 2019, Morten Nielsen. 34 | 35 | Version: Notarize 1.0.0 NotarizeKit 1.0.0 36 | 37 | Usage: --package --username --password --primary-bundle-id 38 | 39 | Options: 40 | 41 | --package Path to either DMG or zip file. 42 | --username Email associated with Apple Connect. 43 | --password Password for Apple Connect. Can be plain text, but it is recommended to use "@keychain:". 44 | --primary-bundle-id Bundle id of package. e.g. "com.company.appName.dmg". 45 | --asc-provider Specify asc provider. 46 | 47 | --help Display options. 48 | 49 | ``` 50 | 51 | ## What does it actually do? 52 | 1. Uses `$ xcrun altool` to upload the app package to Apple's servers. 53 | 2. Waits for the app to be notarized. Checks every 30 seconds. 54 | 3. Staples the app package with the generated certificate using `$ xcrun stapler staple ` 55 | 56 | ## FAQ 57 | 58 | * Notarize says the package is invalid? 59 | * Notarize will print a UUID, which you can use to see the error log from Apple. The error log can be seen using `$ xcrun altool --notarization-info` 60 | 61 | ## License 62 | MIT © [Morten Nielsen](https://github.com/Mortennn) 63 | -------------------------------------------------------------------------------- /Sources/Notarize/main.swift: -------------------------------------------------------------------------------- 1 | import NotarizeKit 2 | import Foundation 3 | import Rainbow 4 | 5 | public final class Notarize { 6 | private let arguments: [String] 7 | 8 | public init(arguments: [String] = CommandLine.arguments) { 9 | self.arguments = arguments 10 | } 11 | 12 | public func run() throws { 13 | let tokens = tokenizer(arguments: arguments) 14 | print("Uploading to notarization services".blue) 15 | 16 | let response = uploadToNotarizationServices(token: tokens) 17 | print("Successfully uploaded app to notarization service".green) 18 | let UUID = getUUID(xmlString: response) 19 | print("UUID: \(UUID)".green) 20 | 21 | print("Waits for app to be notarized. This might take a while.".blue) 22 | let notarizationStatus = waitForNotarizationToFinsish(UUID: UUID, token: tokens) 23 | switch notarizationStatus { 24 | case "success": 25 | print("App was successfully notarized!".green) 26 | print("Stapling package".blue) 27 | stapleApp(token: tokens) 28 | case "invalid": 29 | print("App was not notarized".red) 30 | print("Check out the error log to see what went wrong".blue) 31 | print("UUID: \(UUID)".blue) 32 | exit(1) 33 | default: 34 | print("Unknown status code. Please report the bug.".yellow) 35 | exit(1) 36 | } 37 | 38 | print("✅ All steps finished successfully!".green) 39 | } 40 | } 41 | 42 | let cli = Notarize() 43 | try! cli.run() 44 | -------------------------------------------------------------------------------- /Sources/NotarizeKit/ErrorCodes.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Errors: Error { 4 | case failure 5 | case errorInStaple 6 | } 7 | 8 | struct ErrorMessage { 9 | static let badArgument = "Invalid argument." 10 | static let success = "Success." 11 | static let keyDoesNotExist = "Key does not exist in keychain." 12 | static let missingRequiredArguments = "Missing required arguments. Required arguments are: --password, --username, --package." 13 | static let uploadFailed = "Upload to notarization services failed." 14 | } 15 | 16 | public func terminate(errorMessage: String) -> Never { 17 | print(errorMessage.red) 18 | exit(1) 19 | } 20 | -------------------------------------------------------------------------------- /Sources/NotarizeKit/Sh.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Rainbow 3 | 4 | public func sh(_ arguments: [String]) throws -> String { 5 | let task = Process() 6 | task.executableURL = URL(fileURLWithPath: "/usr/bin/env") 7 | task.arguments = arguments 8 | 9 | let outputPipe = Pipe() 10 | let errorPipe = Pipe() 11 | 12 | task.standardOutput = outputPipe 13 | task.standardError = errorPipe 14 | 15 | do { 16 | try task.run() 17 | } catch { 18 | print("\(error)".red) 19 | } 20 | 21 | let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() 22 | let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() 23 | 24 | if !errorData.isEmpty { 25 | let error = String(decoding: outputData, as: UTF8.self) 26 | print("Command failed:".red) 27 | print(error.red) 28 | print("Caused by command:") 29 | var command = "" 30 | arguments.forEach { command.append($0) } 31 | print(command) 32 | throw Errors.failure 33 | } 34 | 35 | let output = String(decoding: outputData, as: UTF8.self) 36 | 37 | return output 38 | } 39 | -------------------------------------------------------------------------------- /Sources/NotarizeKit/Tokenizer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Rainbow 3 | 4 | public struct Options { 5 | public static let package = "--package" 6 | public static let username = "--username" 7 | public static let password = "--password" 8 | public static let primaryBundleId = "--primary-bundle-id" 9 | public static let ascProvider = "--asc-provider" 10 | public static let verbose = "--verbose" 11 | public static let help = "--help" 12 | } 13 | 14 | public struct Token { 15 | public var package: String = "" 16 | public var username: String = "" 17 | public var password: String = "" 18 | public var primaryBundleId = "" 19 | public var ascProvider: String? 20 | public var verbose: String? 21 | } 22 | 23 | public func tokenizer(arguments: [String]) -> Token { 24 | var token = Token() 25 | 26 | for argumentItem in arguments.enumerated() { 27 | 28 | if argumentItem.element == Options.help { 29 | help() 30 | terminate(errorMessage: "") 31 | } 32 | 33 | if argumentItem.offset + 1 == arguments.count { 34 | break 35 | } 36 | 37 | let argument = argumentItem.element 38 | let value = arguments[argumentItem.offset + 1] 39 | switch argument { 40 | case Options.package: 41 | token.package = value 42 | case Options.username: 43 | token.username = value 44 | case Options.password: 45 | token.password = value 46 | case Options.primaryBundleId: 47 | token.primaryBundleId = value 48 | case Options.verbose: 49 | token.verbose = "true" 50 | case Options.ascProvider: 51 | token.ascProvider = value 52 | default: 53 | continue 54 | } 55 | } 56 | 57 | if token.package.isEmpty || token.password.isEmpty || token.username.isEmpty || token.primaryBundleId.isEmpty { 58 | print("--package, --username, --password and --primary-bundle-id is required.".red) 59 | exit(1) 60 | } 61 | 62 | return token 63 | } 64 | 65 | internal func help() { 66 | print(""" 67 | 68 | Copyright (c) 2019, Morten Nielsen. 69 | 70 | Version: Notarize 1.0.0 NotarizeKit 1.0.0 71 | 72 | Usage: --package --username --password --primary-bundle-id --asc-provider 73 | 74 | Options: 75 | 76 | --package Path to either DMG or zip file. 77 | --username Email associated with Apple Connect. 78 | --password Password for Apple Connect. Can be plain text, but it is recommended to use "@keychain:". 79 | --primary-bundle-id Bundle id of package. e.g. "com.company.appName.dmg". 80 | --asc-provider Specify asc provider. 81 | 82 | --help Display options. 83 | 84 | """) 85 | } 86 | 87 | internal func fileExist(at path: String) -> Bool { 88 | let filemanager = FileManager() 89 | return filemanager.fileExists(atPath: path) 90 | } 91 | -------------------------------------------------------------------------------- /Sources/NotarizeKit/Upload.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Rainbow 3 | import SWXMLHash 4 | 5 | public func uploadToNotarizationServices(token: Token) -> String { 6 | var arguments = [ 7 | "xcrun", 8 | "altool", 9 | "--notarize-app", 10 | "-t", 11 | "osx", 12 | "--output-format", 13 | "xml", 14 | "--username", 15 | "\(token.username)", 16 | "--password", 17 | "\(token.password)", 18 | "--file", 19 | "\(token.package)", 20 | "--primary-bundle-id", 21 | "\(token.primaryBundleId)", 22 | ] 23 | 24 | if let ascProvider = token.ascProvider { 25 | arguments.append("--asc-provider") 26 | arguments.append(ascProvider) 27 | } 28 | 29 | var response = "" 30 | 31 | do { 32 | response = try sh(arguments) 33 | } catch { 34 | terminate(errorMessage: ErrorMessage.uploadFailed) 35 | } 36 | 37 | return response 38 | } 39 | 40 | public func getUUID(xmlString: String) -> String { 41 | let xml = SWXMLHash.config { _ in 42 | // set any config options here 43 | }.parse(xmlString) 44 | guard let uuid = xml["plist"]["dict"]["dict"]["string"].element?.text else { 45 | print("Failed retrieving UUID from notarization response.".red) 46 | exit(0) 47 | } 48 | return uuid 49 | } 50 | 51 | /// Waits for notarization service to finish. 52 | /// 53 | /// - Returns: Returns 'success' or 'invalid' 54 | public func waitForNotarizationToFinsish(UUID: String, token: Token) -> String { 55 | var notarizationStatus = "" 56 | while notarizationStatus.isEmpty || notarizationStatus == "in progress" { 57 | if notarizationStatus.isEmpty { 58 | print("Waiting for status".blue) 59 | } else { 60 | print("Waiting for notarization service to finish...".blue) 61 | } 62 | 63 | sleep(30) 64 | 65 | var response = "" 66 | do { 67 | response = try sh([ 68 | "xcrun", 69 | "altool", 70 | "--notarization-info", 71 | UUID, 72 | "--username", 73 | token.username, 74 | "--password", 75 | token.password, 76 | "--output-format", 77 | "xml", 78 | ]) 79 | } catch { 80 | print("Checking status failed".red) 81 | } 82 | 83 | guard let status = getStatus(xmlResponse: response) else { continue } 84 | notarizationStatus = status 85 | 86 | } 87 | 88 | return notarizationStatus 89 | } 90 | 91 | public func getStatus(xmlResponse: String) -> String? { 92 | let xml = SWXMLHash.config { _ in }.parse(xmlResponse) 93 | let notarizationInfo = xml["plist"]["dict"]["dict"].children 94 | 95 | for item in notarizationInfo.enumerated() { 96 | guard let text = item.element.element?.text else { continue } 97 | if text == "Status" { 98 | guard let status = notarizationInfo[item.offset + 1].element?.text else { 99 | return nil 100 | } 101 | return status 102 | } 103 | 104 | } 105 | 106 | return nil 107 | } 108 | 109 | public func stapleApp(token: Token) { 110 | do { 111 | _ = try sh([ 112 | "xcrun", 113 | "stapler", 114 | "staple", 115 | token.package, 116 | ]) 117 | } catch { 118 | print("Stapling package failed".red) 119 | print("Command: xcrun stapler staple \(token.package)".blue) 120 | exit(1) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import CommandLineToolTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += CommandLineToolTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/NotarizeTests/ShTests.swift: -------------------------------------------------------------------------------- 1 | import NotarizeKit 2 | import XCTest 3 | 4 | class ShTests: XCTestCase { 5 | func testShSuccess() { 6 | let validCommand = ["pwd"] 7 | var output = "" 8 | do { 9 | output = try sh(validCommand) 10 | } catch { 11 | XCTFail("Should not fail") 12 | } 13 | XCTAssertTrue(!output.isEmpty) 14 | } 15 | 16 | func testShFailure() { 17 | let invalidCommand = ["invalidCommand"] 18 | do { 19 | _ = try sh(invalidCommand) 20 | } catch { 21 | XCTAssertTrue(true) 22 | return 23 | } 24 | XCTFail("Should not fail") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/NotarizeTests/Tests.swift: -------------------------------------------------------------------------------- 1 | import NotarizeKit 2 | import XCTest 3 | 4 | class Tests: XCTestCase { 5 | struct TestTokens { 6 | static let package = "~/app.dmg" 7 | static let username = "mail@gmail.com" 8 | static let password = "testPassword" 9 | static let primaryBundleId = "com.company.appname.dmg" 10 | } 11 | 12 | var basicTestToken: Token { 13 | return tokenizer(arguments: [ 14 | Options.package, 15 | TestTokens.package, 16 | Options.username, 17 | TestTokens.username, 18 | Options.password, 19 | TestTokens.password, 20 | Options.primaryBundleId, 21 | TestTokens.primaryBundleId, 22 | ]) 23 | } 24 | 25 | func testTokenizer() { 26 | let token = basicTestToken 27 | 28 | XCTAssertEqual(token.package, TestTokens.package) 29 | XCTAssertEqual(token.username, TestTokens.username) 30 | XCTAssertEqual(token.password, TestTokens.password) 31 | XCTAssertEqual(token.primaryBundleId, TestTokens.primaryBundleId) 32 | XCTAssertNil(token.ascProvider) 33 | } 34 | 35 | func testGetUUID() {} 36 | } 37 | -------------------------------------------------------------------------------- /Tests/NotarizeTests/UploadTests.swift: -------------------------------------------------------------------------------- 1 | import NotarizeKit 2 | import Rainbow 3 | import SWXMLHash 4 | import XCTest 5 | 6 | class UploadTests: XCTestCase { 7 | let testUUID = "1111aa11-1111-1a1a-11aa-111aaaa11a11" 8 | 9 | func testGetUUID() { 10 | let testXMLResponse = """ 11 | 12 | 13 | 14 | 15 | notarization-upload 16 | 17 | RequestUUID 18 | \(testUUID) 19 | 20 | os-version 21 | 10.14.3 22 | success-message 23 | No errors uploading '/path/to/app.dmg'. 24 | tool-path 25 | /Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/Frameworks/ITunesSoftwareService.framework 26 | tool-version 27 | 1.1.1138 28 | 29 | 30 | """ 31 | let resultUUID = getUUID(xmlString: testXMLResponse) 32 | XCTAssertEqual(resultUUID, testUUID) 33 | } 34 | 35 | func testGetStatus() { 36 | guard let successStatus = getStatus(xmlResponse: NotarizeResponses.success) else { 37 | XCTFail("Fail in get success status") 38 | return 39 | } 40 | 41 | guard let invalidStatus = getStatus(xmlResponse: NotarizeResponses.invalid) else { 42 | XCTFail("Fail in get success status") 43 | return 44 | } 45 | 46 | guard let inProgressStatus = getStatus(xmlResponse: NotarizeResponses.inProgress) else { 47 | XCTFail("Fail in get success status") 48 | return 49 | } 50 | 51 | XCTAssertEqual(successStatus, "success") 52 | XCTAssertEqual(invalidStatus, "invalid") 53 | XCTAssertEqual(inProgressStatus, "in progress") 54 | 55 | } 56 | } 57 | 58 | struct NotarizeResponses { 59 | static let success = """ 60 | 61 | 62 | 63 | 64 | notarization-info 65 | 66 | Date 67 | 2011-01-28T15:55:12Z 68 | LogFileURL 69 | https://osxapps-ssl.itunes.apple.com/logFile 70 | RequestUUID 71 | 1111aa11-1111-1a1a-11aa-111aaaa11a11 72 | Status 73 | success 74 | Status Code 75 | 2 76 | Status Message 77 | Package Approved 78 | 79 | os-version 80 | 10.14.3 81 | success-message 82 | No errors getting notarization info. 83 | tool-path 84 | /Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/Frameworks/ITunesSoftwareService.framework 85 | tool-version 86 | 1.1.1138 87 | 88 | 89 | """ 90 | static let invalid = """ 91 | 92 | 93 | 94 | 95 | notarization-info 96 | 97 | Date 98 | 2011-01-28T15:55:12Z 99 | LogFileURL 100 | https://osxapps-ssl.itunes.apple.com/logFile 101 | RequestUUID 102 | 1111aa11-1111-1a1a-11aa-111aaaa11a11 103 | Status 104 | invalid 105 | Status Code 106 | 2 107 | Status Message 108 | Package Invalid 109 | 110 | os-version 111 | 10.14.3 112 | success-message 113 | No errors getting notarization info. 114 | tool-path 115 | /Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/Frameworks/ITunesSoftwareService.framework 116 | tool-version 117 | 1.1.1138 118 | 119 | 120 | """ 121 | static let inProgress = """ 122 | 123 | 124 | notarization-info 125 | 126 | Date 127 | 2019-01-31T16:20:47Z 128 | RequestUUID 129 | 1111aa11-1111-1a1a-11aa-111aaaa11a11 130 | Status 131 | in progress 132 | Status Code 133 | 0 134 | Status Message 135 | Package Approved 136 | 137 | os-version 138 | 10.14.3 139 | success-message 140 | No errors getting notarization info. 141 | tool-path 142 | /Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/Frameworks/ITunesSoftwareService.framework 143 | tool-version 144 | 1.1.1138 145 | 146 | 147 | """ 148 | } 149 | -------------------------------------------------------------------------------- /Tests/NotarizeTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !os(macOS) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(NotarizeTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /package.xcconfig: -------------------------------------------------------------------------------- 1 | MACOSX_DEPLOYMENT_TARGET=10.13 -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # name: 4 | # install.sh 5 | # 6 | # description: 7 | # Install Notarize to /usr/local 8 | # 9 | # - /usr/local/bin/notarize 10 | # 11 | # parameters: 12 | # - 1: install location 13 | 14 | PREFIX=${PREFIX:-${1:-/usr/local}} 15 | BASE_DIR=$(cd `dirname $0`; pwd) 16 | 17 | cp -r $BASE_DIR/bin "${PREFIX}" 18 | --------------------------------------------------------------------------------