├── .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 |
5 |
6 |
7 |
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 |
--------------------------------------------------------------------------------