diff --git a/.github/workflows/swift-format.yml b/.github/workflows/swift-format.yml new file mode 100644 index 0000000..6ac795c --- /dev/null +++ b/.github/workflows/swift-format.yml @@ -0,0 +1,19 @@ +name: swift-format + +on: + pull_request: + paths: + - '.github/workflows/swift-format.yml' + - '.swift-format' + - '**/*.swift' + +permissions: + contents: read + +jobs: + swift-format: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Lint with swift-format + run: xcrun swift-format lint --strict -r -p . diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml deleted file mode 100644 index b35dc26..0000000 --- a/.github/workflows/swiftlint.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: SwiftLint - -on: - pull_request: - paths: - - '.github/workflows/swiftlint.yml' - - '.swiftlint.yml' - - '**/*.swift' - -permissions: - contents: read - -jobs: - SwiftLint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Swiftlint verification - uses: norio-nomura/action-swiftlint@3.2.1 - with: - args: --strict diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..7ab3302 --- /dev/null +++ b/.swift-format @@ -0,0 +1,12 @@ +{ + "version": 1, + "lineLength": 200, + "indentation": { + "spaces": 4 + }, + "multiElementCollectionTrailingCommas": false, + "rules": { + "AlwaysUseLowerCamelCase": false, + "ReplaceForEachWithForLoop": false + } +} diff --git a/.swiftlint.yml b/.swiftlint.yml deleted file mode 100644 index 3695d01..0000000 --- a/.swiftlint.yml +++ /dev/null @@ -1,12 +0,0 @@ -opt_in_rules: - - sorted_imports - - trailing_closure - - orphaned_doc_comment - -file_length: - ignore_comment_only_lines: true - -disabled_rules: - - line_length - - type_body_length - - todo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3606719 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,18 @@ +# PPPC Utility + +## Swift Concurrency + +- `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` is set on both app and test targets — don't add explicit `@MainActor` to production code or test structs/functions, it's already the default + +## Swift Testing Conventions + +- Place `@Test` and `@Suite` annotations on the line **above** the declaration, not inline +- Use `// when` and `// then` comment blocks; skip `// given` (assumed from context) +- When XCTest assertions have message strings, preserve them as `#expect` messages, not code comments (e.g. `#expect(x == false, "reason")`) +- Avoid `#require` on `Bool?` — it's ambiguous; use `#expect(x == true)` instead +- Capture a baseline of compiler warnings before each phase, then verify no new warnings after. Use this command and compare the output before/after: + ``` + xcodebuild clean build-for-testing -project "PPPC Utility.xcodeproj" -scheme "PPPC Utility" -destination "platform=macOS" 2>&1 | grep -i "warning:" | grep -v "xcodebuild: WARNING" + ``` +- Use parameterized tests with Traits where it reduces duplication; 1–2 args is ideal, max 3 +- Beyond 3 params: create separate tests with some values hard-coded diff --git a/PPPC Utility.xcodeproj/project.pbxproj b/PPPC Utility.xcodeproj/project.pbxproj index fd7dfb4..69780f1 100644 --- a/PPPC Utility.xcodeproj/project.pbxproj +++ b/PPPC Utility.xcodeproj/project.pbxproj @@ -369,7 +369,6 @@ isa = PBXNativeTarget; buildConfigurationList = 6EC409EA214D65BD00BE4F17 /* Build configuration list for PBXNativeTarget "PPPC Utility" */; buildPhases = ( - 49DB95D624991AA800F433CA /* SwiftLint */, 6EC409D6214D65BC00BE4F17 /* Sources */, 6EC409D7214D65BC00BE4F17 /* Frameworks */, 6EC409D8214D65BC00BE4F17 /* Resources */, @@ -463,28 +462,6 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 49DB95D624991AA800F433CA /* SwiftLint */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = SwiftLint; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 5F95AE172315A6AD002E0A22 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -570,9 +547,10 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = XPLDEEDNHE; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "PPPC UtilityTests/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -597,9 +575,10 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = XPLDEEDNHE; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "PPPC UtilityTests/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -740,9 +719,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "Resources/PPPC Utility.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = XPLDEEDNHE; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -756,7 +736,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -766,9 +746,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "Resources/PPPC Utility.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = XPLDEEDNHE; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -782,7 +763,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; diff --git a/PPPC UtilityTests/Helpers/ModelBuilder.swift b/PPPC UtilityTests/Helpers/ModelBuilder.swift index 30514ee..7073724 100644 --- a/PPPC UtilityTests/Helpers/ModelBuilder.swift +++ b/PPPC UtilityTests/Helpers/ModelBuilder.swift @@ -28,6 +28,7 @@ import Cocoa @testable import PPPC_Utility +@MainActor class ModelBuilder { var model: Model diff --git a/PPPC UtilityTests/Helpers/TCCProfileBuilder.swift b/PPPC UtilityTests/Helpers/TCCProfileBuilder.swift index 67d9830..9d120e2 100644 --- a/PPPC UtilityTests/Helpers/TCCProfileBuilder.swift +++ b/PPPC UtilityTests/Helpers/TCCProfileBuilder.swift @@ -33,8 +33,9 @@ class TCCProfileBuilder: NSObject { // MARK: - build testing objects func buildTCCPolicy(allowed: Bool?, authorization: TCCPolicyAuthorizationValue?) -> TCCPolicy { - var policy = TCCPolicy(identifier: "policy id", codeRequirement: "policy code req", - receiverIdentifier: "policy receiver id", receiverCodeRequirement: "policy receiver code req") + var policy = TCCPolicy( + identifier: "policy id", codeRequirement: "policy code req", + receiverIdentifier: "policy receiver id", receiverCodeRequirement: "policy receiver code req") policy.comment = "policy comment" policy.identifierType = "policy id type" policy.receiverIdentifierType = "policy receiver id type" @@ -44,27 +45,31 @@ class TCCProfileBuilder: NSObject { } func buildTCCPolicies(allowed: Bool?, authorization: TCCPolicyAuthorizationValue?) -> [String: [TCCPolicy]] { - return ["SystemPolicyAllFiles": [buildTCCPolicy(allowed: allowed, authorization: authorization)], - "AppleEvents": [buildTCCPolicy(allowed: allowed, authorization: authorization)]] + return [ + "SystemPolicyAllFiles": [buildTCCPolicy(allowed: allowed, authorization: authorization)], + "AppleEvents": [buildTCCPolicy(allowed: allowed, authorization: authorization)] + ] } func buildTCCContent(_ contentIndex: Int, allowed: Bool?, authorization: TCCPolicyAuthorizationValue?) -> TCCProfile.Content { - return TCCProfile.Content(payloadDescription: "Content Desc \(contentIndex)", - displayName: "Content Name \(contentIndex)", - identifier: "Content ID \(contentIndex)", - organization: "Content Org \(contentIndex)", - type: "Content type \(contentIndex)", - uuid: "Content UUID \(contentIndex)", - version: contentIndex, - services: buildTCCPolicies(allowed: allowed, authorization: authorization)) + return TCCProfile.Content( + payloadDescription: "Content Desc \(contentIndex)", + displayName: "Content Name \(contentIndex)", + identifier: "Content ID \(contentIndex)", + organization: "Content Org \(contentIndex)", + type: "Content type \(contentIndex)", + uuid: "Content UUID \(contentIndex)", + version: contentIndex, + services: buildTCCPolicies(allowed: allowed, authorization: authorization)) } func buildProfile(allowed: Bool? = nil, authorization: TCCPolicyAuthorizationValue? = nil) -> TCCProfile { - var profile = TCCProfile(organization: "Test Org", - identifier: "Test ID", - displayName: "Test Name", - payloadDescription: "Test Desc", - services: [:]) + var profile = TCCProfile( + organization: "Test Org", + identifier: "Test ID", + displayName: "Test Name", + payloadDescription: "Test Desc", + services: [:]) profile.content = [buildTCCContent(1, allowed: allowed, authorization: authorization)] profile.version = 100 profile.uuid = "the uuid" diff --git a/PPPC UtilityTests/ModelTests/ModelTests.swift b/PPPC UtilityTests/ModelTests/ModelTests.swift index 85ea06e..c2ad6f1 100644 --- a/PPPC UtilityTests/ModelTests/ModelTests.swift +++ b/PPPC UtilityTests/ModelTests/ModelTests.swift @@ -26,110 +26,107 @@ // import Foundation -import XCTest +import Testing @testable import PPPC_Utility -class ModelTests: XCTestCase { +@Suite +struct ModelTests { - var model: Model! - - override func setUp() { - super.setUp() - model = Model() - } + let model = Model() // MARK: - tests for getExecutableFrom* - func testGetExecutableBasedOnIdentifierAndCodeRequirement_BundleIdentifierType() { - // given - let identifier = "com.example.App" - let codeRequirement = "testCodeRequirement" + @Test + func getExecutableBasedOnIdentifierAndCodeRequirement_BundleIdentifierType() async { + let identifier = "com.example.App" + let codeRequirement = "testCodeRequirement" // when - let executable = model.getExecutableFrom(identifier: identifier, codeRequirement: codeRequirement) + let executable = await model.getExecutableFrom(identifier: identifier, codeRequirement: codeRequirement) // then - XCTAssertEqual(executable.displayName, "App") - XCTAssertEqual(executable.codeRequirement, codeRequirement) - XCTAssertEqual(executable.iconPath, IconFilePath.application) + #expect(executable.displayName == "App") + #expect(executable.codeRequirement == codeRequirement) + #expect(executable.iconPath == IconFilePath.application) } - func testGetExecutableBasedOnIdentifierAndCodeRequirement_PathIdentifierType() { - // given + @Test + func getExecutableBasedOnIdentifierAndCodeRequirement_PathIdentifierType() async { let identifier = "/myGreatPath/Awesome/Binary" let codeRequirement = "testCodeRequirement" // when - let executable = model.getExecutableFrom(identifier: identifier, codeRequirement: codeRequirement) + let executable = await model.getExecutableFrom(identifier: identifier, codeRequirement: codeRequirement) // then - XCTAssertEqual(executable.displayName, "Binary") - XCTAssertEqual(executable.codeRequirement, codeRequirement) - XCTAssertEqual(executable.iconPath, IconFilePath.binary) + #expect(executable.displayName == "Binary") + #expect(executable.codeRequirement == codeRequirement) + #expect(executable.iconPath == IconFilePath.binary) } - func testGetExecutableFromComputerBasedOnIdentifier() { - // given - let identifier = "com.apple.Safari" - let codeRequirement = "randomReq" + @Test + func getExecutableFromComputerBasedOnIdentifier() async { + let identifier = "com.apple.Safari" + let codeRequirement = "randomReq" // when - let executable = model.getExecutableFrom(identifier: identifier, codeRequirement: codeRequirement) + let executable = await model.getExecutableFrom(identifier: identifier, codeRequirement: codeRequirement) // then - XCTAssertEqual(executable.displayName, "Safari") - XCTAssertNotEqual(executable.iconPath, IconFilePath.application) - XCTAssertNotEqual(codeRequirement, executable.codeRequirement) + #expect(executable.displayName == "Safari") + #expect(executable.iconPath != IconFilePath.application) + #expect(executable.codeRequirement != codeRequirement) } - func testGetExecutableFromSelectedExecutables() { - // given + @Test + func getExecutableFromSelectedExecutables() async { let expectedIdentifier = "com.something.1" - let executable = model.getExecutableFrom(identifier: expectedIdentifier, codeRequirement: "testReq") - let executableSecond = model.getExecutableFrom(identifier: "com.something.2", codeRequirement: "testReq2") + let executable = await model.getExecutableFrom(identifier: expectedIdentifier, codeRequirement: "testReq") + let executableSecond = await model.getExecutableFrom(identifier: "com.something.2", codeRequirement: "testReq2") model.selectedExecutables = [executable, executableSecond] // when let existingExecutable = model.getExecutableFromSelectedExecutables(bundleIdentifier: "com.something.1") // then - XCTAssertNotNil(existingExecutable) - XCTAssertEqual(existingExecutable?.identifier, expectedIdentifier) - XCTAssertEqual(existingExecutable?.displayName, "1") - XCTAssertEqual(existingExecutable?.iconPath, IconFilePath.application) + #expect(existingExecutable != nil) + #expect(existingExecutable?.identifier == expectedIdentifier) + #expect(existingExecutable?.displayName == "1") + #expect(existingExecutable?.iconPath == IconFilePath.application) } - func testGetExecutableFromSelectedExecutables_Path() { - // given + @Test + func getExecutableFromSelectedExecutables_Path() async { let expectedIdentifier = "/path/something/Special" - let executableOneMore = model.getExecutableFrom(identifier: "/path/something/Special1", codeRequirement: "testReq") - let executable = model.getExecutableFrom(identifier: expectedIdentifier, codeRequirement: "testReq") - let executableSecond = model.getExecutableFrom(identifier: "com.something.2", codeRequirement: "testReq2") + let executableOneMore = await model.getExecutableFrom(identifier: "/path/something/Special1", codeRequirement: "testReq") + let executable = await model.getExecutableFrom(identifier: expectedIdentifier, codeRequirement: "testReq") + let executableSecond = await model.getExecutableFrom(identifier: "com.something.2", codeRequirement: "testReq2") model.selectedExecutables = [executableOneMore, executable, executableSecond] // when let existingExecutable = model.getExecutableFromSelectedExecutables(bundleIdentifier: "/path/something/Special") // then - XCTAssertNotNil(existingExecutable) - XCTAssertEqual(existingExecutable?.identifier, expectedIdentifier) - XCTAssertEqual(existingExecutable?.displayName, "Special") - XCTAssertEqual(existingExecutable?.iconPath, IconFilePath.binary) + #expect(existingExecutable != nil) + #expect(existingExecutable?.identifier == expectedIdentifier) + #expect(existingExecutable?.displayName == "Special") + #expect(existingExecutable?.iconPath == IconFilePath.binary) } - func testGetExecutableFromSelectedExecutables_Empty() { + @Test + func getExecutableFromSelectedExecutables_Empty() { // when let existingExecutable = model.getExecutableFromSelectedExecutables(bundleIdentifier: "com.something.1") // then - XCTAssertNil(existingExecutable) + #expect(existingExecutable == nil) } // MARK: - tests for exportProfile - func testExportProfileWithAppleEventsAndAuthorization() { - // given + @Test + func exportProfileWithAppleEventsAndAuthorization() { let exe1 = Executable(identifier: "one", codeRequirement: "oneReq") let exe2 = Executable(identifier: "two", codeRequirement: "twoReq") @@ -142,181 +139,178 @@ class ModelTests: XCTestCase { let profile = model.exportProfile(organization: "Org", identifier: "ID", displayName: "Name", payloadDescription: "Desc") // then check top level settings - XCTAssertEqual("Org", profile.organization) - XCTAssertEqual("ID", profile.identifier) - XCTAssertEqual("Name", profile.displayName) - XCTAssertEqual("Desc", profile.payloadDescription) - XCTAssertEqual("System", profile.scope) - XCTAssertEqual("Configuration", profile.type) - XCTAssertNotNil(profile.uuid) - XCTAssertEqual(1, profile.version) + #expect(profile.organization == "Org") + #expect(profile.identifier == "ID") + #expect(profile.displayName == "Name") + #expect(profile.payloadDescription == "Desc") + #expect(profile.scope == "System") + #expect(profile.type == "Configuration") + #expect(!profile.uuid.isEmpty) + #expect(profile.version == 1) // then check policy settings // then verify the payload content top level - XCTAssertEqual(1, profile.content.count) + #expect(profile.content.count == 1) profile.content.forEach { content in - XCTAssertNotNil(content.uuid) - XCTAssertEqual(1, content.version) + #expect(!content.uuid.isEmpty) + #expect(content.version == 1) // then verify the services - XCTAssertEqual(2, content.services.count) + #expect(content.services.count == 2) let appleEvents = content.services["AppleEvents"] - XCTAssertNotNil(appleEvents) + #expect(appleEvents != nil) let appleEventsPolicy = appleEvents?.first - XCTAssertEqual("one", appleEventsPolicy?.identifier) - XCTAssertEqual("oneReq", appleEventsPolicy?.codeRequirement) - XCTAssertEqual("bundleID", appleEventsPolicy?.identifierType) - XCTAssertEqual("two", appleEventsPolicy?.receiverIdentifier) - XCTAssertEqual("twoReq", appleEventsPolicy?.receiverCodeRequirement) - XCTAssertEqual("bundleID", appleEventsPolicy?.receiverIdentifierType) - XCTAssertTrue(appleEventsPolicy?.authorization == .allow) + #expect(appleEventsPolicy?.identifier == "one") + #expect(appleEventsPolicy?.codeRequirement == "oneReq") + #expect(appleEventsPolicy?.identifierType == "bundleID") + #expect(appleEventsPolicy?.receiverIdentifier == "two") + #expect(appleEventsPolicy?.receiverCodeRequirement == "twoReq") + #expect(appleEventsPolicy?.receiverIdentifierType == "bundleID") + #expect(appleEventsPolicy?.authorization == .allow) let allFiles = content.services["SystemPolicyAllFiles"] - XCTAssertNotNil(allFiles) + #expect(allFiles != nil) let allFilesPolicy = allFiles?.first - XCTAssertEqual("two", allFilesPolicy?.identifier) - XCTAssertEqual("twoReq", allFilesPolicy?.codeRequirement) - XCTAssertEqual("bundleID", allFilesPolicy?.identifierType) - XCTAssertNil(allFilesPolicy?.receiverIdentifier) - XCTAssertNil(allFilesPolicy?.receiverCodeRequirement) - XCTAssertNil(allFilesPolicy?.receiverIdentifierType) - XCTAssertTrue(allFilesPolicy?.authorization == .allowStandardUserToSetSystemService) + #expect(allFilesPolicy?.identifier == "two") + #expect(allFilesPolicy?.codeRequirement == "twoReq") + #expect(allFilesPolicy?.identifierType == "bundleID") + #expect(allFilesPolicy?.receiverIdentifier == nil) + #expect(allFilesPolicy?.receiverCodeRequirement == nil) + #expect(allFilesPolicy?.receiverIdentifierType == nil) + #expect(allFilesPolicy?.authorization == .allowStandardUserToSetSystemService) } } // MARK: - tests for importProfile - func testImportProfileUsingAuthorizationKeyAllow() { - // given + @Test + func importProfileUsingAuthorizationKeyAllow() async { let profile = TCCProfileBuilder().buildProfile(authorization: .allow) // when - model.importProfile(tccProfile: profile) + await model.importProfile(tccProfile: profile) // then - XCTAssertEqual(1, model.selectedExecutables.count) - XCTAssertEqual("Allow", model.selectedExecutables.first?.policy.SystemPolicyAllFiles) + #expect(model.selectedExecutables.count == 1) + #expect(model.selectedExecutables.first?.policy.SystemPolicyAllFiles == "Allow") } - func testImportProfileUsingAuthorizationKeyDeny() { - // given + @Test + func importProfileUsingAuthorizationKeyDeny() async { let profile = TCCProfileBuilder().buildProfile(authorization: .deny) // when - model.importProfile(tccProfile: profile) + await model.importProfile(tccProfile: profile) // then - XCTAssertEqual(1, model.selectedExecutables.count) - XCTAssertEqual("Deny", model.selectedExecutables.first?.policy.SystemPolicyAllFiles) + #expect(model.selectedExecutables.count == 1) + #expect(model.selectedExecutables.first?.policy.SystemPolicyAllFiles == "Deny") } - func testImportProfileUsingAuthorizationKeyAllowStandardUsers() { - // given + @Test + func importProfileUsingAuthorizationKeyAllowStandardUsers() async { let profile = TCCProfileBuilder().buildProfile(authorization: .allowStandardUserToSetSystemService) // when - model.importProfile(tccProfile: profile) + await model.importProfile(tccProfile: profile) // then - XCTAssertEqual(1, model.selectedExecutables.count) - XCTAssertEqual("Let Standard Users Approve", model.selectedExecutables.first?.policy.SystemPolicyAllFiles) + #expect(model.selectedExecutables.count == 1) + #expect(model.selectedExecutables.first?.policy.SystemPolicyAllFiles == "Let Standard Users Approve") } - func testImportProfileUsingLegacyAllowKeyTrue() { - // given + @Test + func importProfileUsingLegacyAllowKeyTrue() async { let profile = TCCProfileBuilder().buildProfile(allowed: true) // when - model.importProfile(tccProfile: profile) + await model.importProfile(tccProfile: profile) // then - XCTAssertEqual(1, model.selectedExecutables.count) - XCTAssertEqual("Allow", model.selectedExecutables.first?.policy.SystemPolicyAllFiles) + #expect(model.selectedExecutables.count == 1) + #expect(model.selectedExecutables.first?.policy.SystemPolicyAllFiles == "Allow") } - func testImportProfileUsingLegacyAllowKeyFalse() { - // given + @Test + func importProfileUsingLegacyAllowKeyFalse() async { let profile = TCCProfileBuilder().buildProfile(allowed: false) // when - model.importProfile(tccProfile: profile) + await model.importProfile(tccProfile: profile) // then - XCTAssertEqual(1, model.selectedExecutables.count) - XCTAssertEqual("Deny", model.selectedExecutables.first?.policy.SystemPolicyAllFiles) + #expect(model.selectedExecutables.count == 1) + #expect(model.selectedExecutables.first?.policy.SystemPolicyAllFiles == "Deny") } - func testImportProfileUsingAuthorizationKeyThatIsInvalid() { - // given + @Test + func importProfileUsingAuthorizationKeyThatIsInvalid() async { let profile = TCCProfileBuilder().buildProfile(authorization: "invalidkey") // when - model.importProfile(tccProfile: profile) + await model.importProfile(tccProfile: profile) // then - XCTAssertEqual(1, model.selectedExecutables.count) - XCTAssertEqual("Deny", model.selectedExecutables.first?.policy.SystemPolicyAllFiles) + #expect(model.selectedExecutables.count == 1) + #expect(model.selectedExecutables.first?.policy.SystemPolicyAllFiles == "Deny") } - func testImportProfileUsingAuthorizationKeyTranslatesToAppleEvents() { - // given + @Test + func importProfileUsingAuthorizationKeyTranslatesToAppleEvents() async { let profile = TCCProfileBuilder().buildProfile(authorization: "deny") // when - model.importProfile(tccProfile: profile) + await model.importProfile(tccProfile: profile) // then - XCTAssertEqual(1, model.selectedExecutables.count) - XCTAssertEqual("Deny", model.selectedExecutables.first?.policy.SystemPolicyAllFiles) + #expect(model.selectedExecutables.count == 1) + #expect(model.selectedExecutables.first?.policy.SystemPolicyAllFiles == "Deny") } // MARK: - tests for policyFromString - func testPolicyWhenUsingAllow() { - // given + @Test + func policyWhenUsingAllow() { let app = Executable(identifier: "id", codeRequirement: "req") // when let policy = model.policyFromString(executable: app, value: "Allow") // then - XCTAssertEqual(policy?.authorization, TCCPolicyAuthorizationValue.allow) - XCTAssertNil(policy?.allowed) + #expect(policy?.authorization == .allow) } - func testPolicyWhenUsingDeny() { - // given + @Test + func policyWhenUsingDeny() { let app = Executable(identifier: "id", codeRequirement: "req") // when let policy = model.policyFromString(executable: app, value: "Deny") // then - XCTAssertEqual(policy?.authorization, TCCPolicyAuthorizationValue.deny) - XCTAssertNil(policy?.allowed) + #expect(policy?.authorization == .deny) } - func testPolicyWhenUsingAllowForStandardUsers() { - // given + @Test + func policyWhenUsingAllowForStandardUsers() { let app = Executable(identifier: "id", codeRequirement: "req") // when let policy = model.policyFromString(executable: app, value: "Let Standard Users Approve") // then - XCTAssertEqual(policy?.authorization, TCCPolicyAuthorizationValue.allowStandardUserToSetSystemService) - XCTAssertNil(policy?.allowed) + #expect(policy?.authorization == .allowStandardUserToSetSystemService) } - func testPolicyWhenUsingUnknownValue() { - // given + @Test + func policyWhenUsingUnknownValue() { let app = Executable(identifier: "id", codeRequirement: "req") // when let policy = model.policyFromString(executable: app, value: "For MDM Admins Only") // then - XCTAssertNil(policy, "should have not created the policy with an unknown value") + #expect(policy == nil, "should have not created the policy with an unknown value") } } diff --git a/PPPC UtilityTests/ModelTests/PPPCServicesManagerTests.swift b/PPPC UtilityTests/ModelTests/PPPCServicesManagerTests.swift index d92bc82..ca8bf7e 100644 --- a/PPPC UtilityTests/ModelTests/PPPCServicesManagerTests.swift +++ b/PPPC UtilityTests/ModelTests/PPPCServicesManagerTests.swift @@ -26,65 +26,64 @@ // import Foundation -import XCTest +import Testing @testable import PPPC_Utility -class PPPCServicesManagerTests: XCTestCase { +@Suite +struct PPPCServicesManagerTests { - func testLoadAllServices() { - // given/when + @Test + func loadAllServices() { + // when let actual = PPPCServicesManager() // then - XCTAssertEqual(actual.allServices.count, 21) + #expect(actual.allServices.count == 21) } - func testUserHelp_withEntitlements() throws { - // given + @Test + func userHelp_withEntitlements() throws { let services = PPPCServicesManager() - let service = try XCTUnwrap(services.allServices["Camera"]) + let service = try #require(services.allServices["Camera"]) // when let actual = service.userHelp // then - XCTAssertEqual(actual, "Use to deny specified apps access to the camera.\n\nMDM Key: Camera\nRelated entitlements: [\"com.apple.developer.avfoundation.multitasking-camera-access\", \"com.apple.security.device.camera\"]") + #expect( + actual + == "Use to deny specified apps access to the camera.\n\nMDM Key: Camera\nRelated entitlements: [\"com.apple.developer.avfoundation.multitasking-camera-access\", \"com.apple.security.device.camera\"]" + ) } - func testUserHelp_withoutEntitlements() throws { - // given + @Test + func userHelp_withoutEntitlements() throws { let services = PPPCServicesManager() - let service = try XCTUnwrap(services.allServices["ScreenCapture"]) + let service = try #require(services.allServices["ScreenCapture"]) // when let actual = service.userHelp // then - XCTAssertEqual(actual, "Deny specified apps access to capture (read) the contents of the system display.\n\nMDM Key: ScreenCapture") + #expect(actual == "Deny specified apps access to capture (read) the contents of the system display.\n\nMDM Key: ScreenCapture") } - func testCameraIsDenyOnly() throws { - // given + @Test + func cameraIsDenyOnly() throws { let services = PPPCServicesManager() - let service = try XCTUnwrap(services.allServices["Camera"]) - - // when - let actual = try XCTUnwrap(service.denyOnly) + let service = try #require(services.allServices["Camera"]) // then - XCTAssertTrue(actual) + #expect(service.denyOnly == true) } - func testScreenCaptureAllowsStandardUsers() throws { - // given + @Test + func screenCaptureAllowsStandardUsers() throws { let services = PPPCServicesManager() - let service = try XCTUnwrap(services.allServices["ScreenCapture"]) - - // when - let actual = try XCTUnwrap(service.allowStandardUsers) + let service = try #require(services.allServices["ScreenCapture"]) // then - XCTAssertTrue(actual) + #expect(service.allowStandardUsers == true) } } diff --git a/PPPC UtilityTests/ModelTests/SemanticVersionTests.swift b/PPPC UtilityTests/ModelTests/SemanticVersionTests.swift index 93e788c..4c5c8c1 100644 --- a/PPPC UtilityTests/ModelTests/SemanticVersionTests.swift +++ b/PPPC UtilityTests/ModelTests/SemanticVersionTests.swift @@ -26,70 +26,41 @@ // import Foundation -@testable import PPPC_Utility -import XCTest +import Testing -class SemanticVersionTests: XCTestCase { - func testLessThan() { - // given - let version = SemanticVersion(major: 10, minor: 7, patch: 1) +@testable import PPPC_Utility - // when - let shouldBeLessThan = version < SemanticVersion(major: 10, minor: 7, patch: 4) - let shouldBeLessThan2 = version < SemanticVersion(major: 10, minor: 8, patch: 1) - let shouldBeLessThan3 = version < SemanticVersion(major: 11, minor: 7, patch: 1) - let shouldNotBeLessThan = version < SemanticVersion(major: 10, minor: 7, patch: 1) - let shouldNotBeLessThan2 = version < SemanticVersion(major: 10, minor: 7, patch: 0) - let shouldNotBeLessThan3 = version < SemanticVersion(major: 10, minor: 6, patch: 1) - let shouldNotBeLessThan4 = version < SemanticVersion(major: 9, minor: 7, patch: 1) +@Suite +struct SemanticVersionTests { + let version = SemanticVersion(major: 10, minor: 7, patch: 1) - // then - XCTAssertTrue(shouldBeLessThan) - XCTAssertTrue(shouldBeLessThan2) - XCTAssertTrue(shouldBeLessThan3) - XCTAssertFalse(shouldNotBeLessThan) - XCTAssertFalse(shouldNotBeLessThan2) - XCTAssertFalse(shouldNotBeLessThan3) - XCTAssertFalse(shouldNotBeLessThan4) + @Test + func lessThan() { + #expect(version < SemanticVersion(major: 10, minor: 7, patch: 4)) + #expect(version < SemanticVersion(major: 10, minor: 8, patch: 1)) + #expect(version < SemanticVersion(major: 11, minor: 7, patch: 1)) + #expect(!(version < SemanticVersion(major: 10, minor: 7, patch: 1))) + #expect(!(version < SemanticVersion(major: 10, minor: 7, patch: 0))) + #expect(!(version < SemanticVersion(major: 10, minor: 6, patch: 1))) + #expect(!(version < SemanticVersion(major: 9, minor: 7, patch: 1))) } - func testEquality() { - // given - let version = SemanticVersion(major: 10, minor: 7, patch: 1) - - // when - let shouldBeEqual = version == SemanticVersion(major: 10, minor: 7, patch: 1) - let shouldBeNotEqual = version == SemanticVersion(major: 10, minor: 7, patch: 4) - let shouldBeNotEqual2 = version == SemanticVersion(major: 10, minor: 8, patch: 1) - let shouldBeNotEqual3 = version == SemanticVersion(major: 11, minor: 7, patch: 1) - - // then - XCTAssertTrue(shouldBeEqual) - XCTAssertFalse(shouldBeNotEqual) - XCTAssertFalse(shouldBeNotEqual2) - XCTAssertFalse(shouldBeNotEqual3) + @Test + func equality() { + #expect(version == SemanticVersion(major: 10, minor: 7, patch: 1)) + #expect(version != SemanticVersion(major: 10, minor: 7, patch: 4)) + #expect(version != SemanticVersion(major: 10, minor: 8, patch: 1)) + #expect(version != SemanticVersion(major: 11, minor: 7, patch: 1)) } - func testGreaterThan() { - // given - let version = SemanticVersion(major: 10, minor: 7, patch: 1) - - // when - let shouldNotBeGreaterThan = version > SemanticVersion(major: 10, minor: 7, patch: 4) - let shouldNotBeGreaterThan2 = version > SemanticVersion(major: 10, minor: 8, patch: 1) - let shouldNotBeGreaterThan3 = version > SemanticVersion(major: 11, minor: 7, patch: 1) - let shouldNotBeGreaterThan4 = version > SemanticVersion(major: 10, minor: 7, patch: 1) - let shouldBeGreaterThan = version > SemanticVersion(major: 10, minor: 7, patch: 0) - let shouldBeGreaterThan2 = version > SemanticVersion(major: 10, minor: 6, patch: 1) - let shouldBeGreaterThan3 = version > SemanticVersion(major: 9, minor: 7, patch: 1) - - // then - XCTAssertFalse(shouldNotBeGreaterThan) - XCTAssertFalse(shouldNotBeGreaterThan2) - XCTAssertFalse(shouldNotBeGreaterThan3) - XCTAssertFalse(shouldNotBeGreaterThan4) - XCTAssertTrue(shouldBeGreaterThan) - XCTAssertTrue(shouldBeGreaterThan2) - XCTAssertTrue(shouldBeGreaterThan3) + @Test + func greaterThan() { + #expect(!(version > SemanticVersion(major: 10, minor: 7, patch: 4))) + #expect(!(version > SemanticVersion(major: 10, minor: 8, patch: 1))) + #expect(!(version > SemanticVersion(major: 11, minor: 7, patch: 1))) + #expect(!(version > SemanticVersion(major: 10, minor: 7, patch: 1))) + #expect(version > SemanticVersion(major: 10, minor: 7, patch: 0)) + #expect(version > SemanticVersion(major: 10, minor: 6, patch: 1)) + #expect(version > SemanticVersion(major: 9, minor: 7, patch: 1)) } } diff --git a/PPPC UtilityTests/NetworkingTests/JamfProAPIClientTests.swift b/PPPC UtilityTests/NetworkingTests/JamfProAPIClientTests.swift index 308decd..f44979f 100644 --- a/PPPC UtilityTests/NetworkingTests/JamfProAPIClientTests.swift +++ b/PPPC UtilityTests/NetworkingTests/JamfProAPIClientTests.swift @@ -6,22 +6,23 @@ // Copyright (c) 2023 Jamf Software import Foundation -import XCTest +import Testing @testable import PPPC_Utility -class JamfProAPIClientTests: XCTestCase { - func testOAuthTokenRequest() throws { - // given - let authManager = NetworkAuthManager(username: "", password: "") - let apiClient = JamfProAPIClient(serverUrlString: "https://something", tokenManager: authManager) +@Suite +struct JamfProAPIClientTests { + @Test + func oAuthTokenRequest() throws { + let authManager = NetworkAuthManager(username: "", password: "") + let apiClient = JamfProAPIClient(serverUrlString: "https://something", tokenManager: authManager) - // when - let request = try apiClient.oauthTokenRequest(clientId: "mine&yours", clientSecret: "foo bar") + // when + let request = try apiClient.oauthTokenRequest(clientId: "mine&yours", clientSecret: "foo bar") - // then - let body = try XCTUnwrap(request.httpBody) - let bodyString = String(data: body, encoding: .utf8) - XCTAssertEqual(bodyString, "grant_type=client_credentials&client_id=mine%26yours&client_secret=foo%20bar") - } + // then + let body = try #require(request.httpBody) + let bodyString = String(data: body, encoding: .utf8) + #expect(bodyString == "grant_type=client_credentials&client_id=mine%26yours&client_secret=foo%20bar") + } } diff --git a/PPPC UtilityTests/NetworkingTests/NetworkAuthManagerTests.swift b/PPPC UtilityTests/NetworkingTests/NetworkAuthManagerTests.swift index d54ddd4..efa97e1 100644 --- a/PPPC UtilityTests/NetworkingTests/NetworkAuthManagerTests.swift +++ b/PPPC UtilityTests/NetworkingTests/NetworkAuthManagerTests.swift @@ -26,11 +26,11 @@ // import Foundation -import XCTest +import Testing @testable import PPPC_Utility -/// Fake networking class for testing. No actual network was used in the making of this test. +/// Fake networking class for testing. No actual network was used in the making of this test. class MockNetworking: Networking { var errorToThrow: Error? @@ -43,17 +43,18 @@ class MockNetworking: Networking { throw error } - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let expiration = try XCTUnwrap(formatter.date(from: "2950-06-22T22:05:58.81Z")) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiration = try #require(formatter.date(from: "2950-06-22T22:05:58.81Z")) - return Token(value: "xyz", expiresAt: expiration) + return Token(value: "xyz", expiresAt: expiration) } } -class NetworkAuthManagerTests: XCTestCase { - func testValidToken() async throws { - // given +@Suite +struct NetworkAuthManagerTests { + @Test + func validToken() async throws { let authManager = NetworkAuthManager(username: "test", password: "none") let networking = MockNetworking(tokenManager: authManager) @@ -61,102 +62,82 @@ class NetworkAuthManagerTests: XCTestCase { let token = try await authManager.validToken(networking: networking) // then - XCTAssertEqual(token.value, "xyz") - XCTAssertTrue(token.isValid) + #expect(token.value == "xyz") + #expect(token.isValid) } - func testValidTokenNetworkFailure() async throws { - // given + @Test + func validTokenNetworkFailure() async { let authManager = NetworkAuthManager(username: "test", password: "none") let networking = MockNetworking(tokenManager: authManager) networking.errorToThrow = NetworkingError.serverResponse(500, "Bad server") - // when - do { - _ = try await authManager.validToken(networking: networking) - XCTFail("Expected to throw from `validToken` call") - } catch { - // then - XCTAssertEqual(error as? NetworkingError, NetworkingError.serverResponse(500, "Bad server")) + // when/then + await #expect(throws: NetworkingError.serverResponse(500, "Bad server")) { + try await authManager.validToken(networking: networking) } } - func testValidTokenBearerAuthNotSupported() async throws { - // given + @Test + func validTokenBearerAuthNotSupported() async throws { let authManager = NetworkAuthManager(username: "test", password: "none") let networking = MockNetworking(tokenManager: authManager) networking.errorToThrow = NetworkingError.serverResponse(404, "No such page") // default is that bearer auth is supported. - let firstCheckBearerAuthSupported = await authManager.bearerAuthSupported() - XCTAssertTrue(firstCheckBearerAuthSupported) + let firstCheckBearerAuthSupported = authManager.bearerAuthSupported() + #expect(firstCheckBearerAuthSupported) - // when - do { - _ = try await authManager.validToken(networking: networking) - XCTFail("Expected to throw from `validToken` call") - } catch { - // then - should throw a `bearerAuthNotSupported` error - XCTAssertEqual(error as? AuthError, AuthError.bearerAuthNotSupported) + // when/then + await #expect(throws: AuthError.bearerAuthNotSupported) { + try await authManager.validToken(networking: networking) } // The authManager should now know that bearer auth is not supported - let secondCheckBearerAuthSupported = await authManager.bearerAuthSupported() - XCTAssertFalse(secondCheckBearerAuthSupported) + let secondCheckBearerAuthSupported = authManager.bearerAuthSupported() + #expect(!secondCheckBearerAuthSupported) } - func testValidTokenInvalidUsernamePassword() async throws { - // given + @Test + func validTokenInvalidUsernamePassword() async { let authManager = NetworkAuthManager(username: "test", password: "none") let networking = MockNetworking(tokenManager: authManager) networking.errorToThrow = NetworkingError.serverResponse(401, "Not authorized") - // when - do { - _ = try await authManager.validToken(networking: networking) - XCTFail("Expected to throw from `validToken` call") - } catch { - // then - should throw a `invalidUsernamePassword` error - XCTAssertEqual(error as? AuthError, AuthError.invalidUsernamePassword) + // when/then + await #expect(throws: AuthError.invalidUsernamePassword) { + try await authManager.validToken(networking: networking) } } - func testBasicAuthString() throws { - // given + @Test + func basicAuthString() throws { let authManager = NetworkAuthManager(username: "test", password: "none") // when let actual = try authManager.basicAuthString() // then - XCTAssertEqual(actual, "dGVzdDpub25l") + #expect(actual == "dGVzdDpub25l") } - func testBasicAuthStringEmptyUsername() throws { - // given + @Test + func basicAuthStringEmptyUsername() { let authManager = NetworkAuthManager(username: "", password: "none") - // when - do { - _ = try authManager.basicAuthString() - XCTFail("Expected to throw from `basicAuthString` call") - } catch { - // then - should throw a `invalidUsernamePassword` error - XCTAssertEqual(error as? AuthError, AuthError.invalidUsernamePassword) + // when/then + #expect(throws: AuthError.invalidUsernamePassword) { + try authManager.basicAuthString() } } - func testBasicAuthStringEmptyPassword() throws { - // given + @Test + func basicAuthStringEmptyPassword() { let authManager = NetworkAuthManager(username: "mine", password: "") - // when - do { - _ = try authManager.basicAuthString() - XCTFail("Expected to throw from `basicAuthString` call") - } catch { - // then - should throw a `invalidUsernamePassword` error - XCTAssertEqual(error as? AuthError, AuthError.invalidUsernamePassword) + // when/then + #expect(throws: AuthError.invalidUsernamePassword) { + try authManager.basicAuthString() } } } diff --git a/PPPC UtilityTests/NetworkingTests/TokenTests.swift b/PPPC UtilityTests/NetworkingTests/TokenTests.swift index 973e1d5..a7845fa 100644 --- a/PPPC UtilityTests/NetworkingTests/TokenTests.swift +++ b/PPPC UtilityTests/NetworkingTests/TokenTests.swift @@ -26,100 +26,101 @@ // import Foundation -import XCTest +import Testing @testable import PPPC_Utility -class TokenTests: XCTestCase { - func testPastIsNotValid() throws { - // given - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let expiration = try XCTUnwrap(formatter.date(from: "2021-06-22T22:05:58.81Z")) - let token = Token(value: "abc", expiresAt: expiration) +@Suite +struct TokenTests { + @Test + func pastIsNotValid() throws { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiration = try #require(formatter.date(from: "2021-06-22T22:05:58.81Z")) + let token = Token(value: "abc", expiresAt: expiration) // when let valid = token.isValid // then - XCTAssertFalse(valid) + #expect(!valid) } - func testFutureIsValid() throws { - // given - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let expiration = try XCTUnwrap(formatter.date(from: "2750-06-22T22:05:58.81Z")) + @Test + func futureIsValid() throws { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiration = try #require(formatter.date(from: "2750-06-22T22:05:58.81Z")) let token = Token(value: "abc", expiresAt: expiration) // when let valid = token.isValid // then - XCTAssertTrue(valid) + #expect(valid) + } + + // MARK: - Decoding + + @Test + func decodeBasicAuthToken() throws { + let jsonText = """ + { + "token": "abc", + "expires": "2750-06-22T22:05:58.81Z" + } + """ + let jsonData = try #require(jsonText.data(using: .utf8)) + let decoder = JSONDecoder() + + // when + let actual = try decoder.decode(Token.self, from: jsonData) + + // then + #expect(actual.value == "abc") + #expect(actual.expiresAt != nil) + #expect(actual.isValid) } - // MARK: - Decoding - - func testDecodeBasicAuthToken() throws { - // given - let jsonText = """ - { - "token": "abc", - "expires": "2750-06-22T22:05:58.81Z" - } - """ - let jsonData = try XCTUnwrap(jsonText.data(using: .utf8)) - let decoder = JSONDecoder() - - // when - let actual = try decoder.decode(Token.self, from: jsonData) - - // then - XCTAssertEqual(actual.value, "abc") - XCTAssertNotNil(actual.expiresAt) - XCTAssertTrue(actual.isValid) - } - - func testDecodeExpiredBasicAuthToken() throws { - // given - let jsonText = """ - { - "token": "abc", - "expires": "1970-10-24T22:05:58.81Z" - } - """ - let jsonData = try XCTUnwrap(jsonText.data(using: .utf8)) - let decoder = JSONDecoder() - - // when - let actual = try decoder.decode(Token.self, from: jsonData) - - // then - XCTAssertEqual(actual.value, "abc") - XCTAssertNotNil(actual.expiresAt) - XCTAssertFalse(actual.isValid) - } - - func testDecodeClientCredentialsAuthToken() throws { - // given - let jsonText = """ - { - "access_token": "abc", - "scope": "api-role:2", - "token_type": "Bearer", - "expires_in": 599 - } - """ - let jsonData = try XCTUnwrap(jsonText.data(using: .utf8)) - let decoder = JSONDecoder() - - // when - let actual = try decoder.decode(Token.self, from: jsonData) - - // then - XCTAssertEqual(actual.value, "abc") - XCTAssertNotNil(actual.expiresAt) - XCTAssertTrue(actual.isValid) - } + @Test + func decodeExpiredBasicAuthToken() throws { + let jsonText = """ + { + "token": "abc", + "expires": "1970-10-24T22:05:58.81Z" + } + """ + let jsonData = try #require(jsonText.data(using: .utf8)) + let decoder = JSONDecoder() + + // when + let actual = try decoder.decode(Token.self, from: jsonData) + + // then + #expect(actual.value == "abc") + #expect(actual.expiresAt != nil) + #expect(!actual.isValid) + } + + @Test + func decodeClientCredentialsAuthToken() throws { + let jsonText = """ + { + "access_token": "abc", + "scope": "api-role:2", + "token_type": "Bearer", + "expires_in": 599 + } + """ + let jsonData = try #require(jsonText.data(using: .utf8)) + let decoder = JSONDecoder() + + // when + let actual = try decoder.decode(Token.self, from: jsonData) + + // then + #expect(actual.value == "abc") + #expect(actual.expiresAt != nil) + #expect(actual.isValid) + } } diff --git a/PPPC UtilityTests/TCCProfileImporterTests/TCCProfileImporterTests.swift b/PPPC UtilityTests/TCCProfileImporterTests/TCCProfileImporterTests.swift index a70dc76..042d5ac 100644 --- a/PPPC UtilityTests/TCCProfileImporterTests/TCCProfileImporterTests.swift +++ b/PPPC UtilityTests/TCCProfileImporterTests/TCCProfileImporterTests.swift @@ -26,101 +26,81 @@ // import Foundation -import XCTest +import Testing @testable import PPPC_Utility -class TCCProfileImporterTests: XCTestCase { +private final class BundleLocator {} - func testMalformedTCCProfile() { - let tccProfileImporter = TCCProfileImporter() - - let resourceURL = getResourceProfile(fileName: "TestTCCProfileSigned-Broken") +@Suite +struct TCCProfileImporterTests { - tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) { tccProfileResult in - switch tccProfileResult { - case .success: - XCTFail("Malformed profile should not succeed") - case .failure(let tccProfileError): - if case TCCProfileImportError.invalidProfileFile = tccProfileError { } else { - XCTFail("Expected invalidProfileFile error, got \(tccProfileError)") - } + @Test + func malformedTCCProfile() throws { + let tccProfileImporter = TCCProfileImporter() + let resourceURL = try getResourceProfile(fileName: "TestTCCProfileSigned-Broken") + + do { + _ = try tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) + Issue.record("Malformed profile should not succeed") + } catch { + if case TCCProfileImportError.invalidProfileFile = error { + } else { + Issue.record("Expected invalidProfileFile error, got \(error)") } } } - func testEmptyContentTCCProfile() { + @Test + func emptyContentTCCProfile() throws { let tccProfileImporter = TCCProfileImporter() - - let resourceURL = getResourceProfile(fileName: "TestTCCUnsignedProfile-Empty") - + let resourceURL = try getResourceProfile(fileName: "TestTCCUnsignedProfile-Empty") let expectedTCCProfileError = TCCProfileImportError.invalidProfileFile(description: "PayloadContent") - tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) { tccProfileResult in - switch tccProfileResult { - case .success: - XCTFail("Empty Content, it shouldn't be success") - case .failure(let tccProfileError): - XCTAssertEqual(tccProfileError.localizedDescription, expectedTCCProfileError.localizedDescription) - } + do { + _ = try tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) + Issue.record("Empty Content, it shouldn't be success") + } catch { + #expect(error.localizedDescription == expectedTCCProfileError.localizedDescription) } } - func testCorrectUnsignedProfileContentData() { + @Test + func correctUnsignedProfileContentData() throws { let tccProfileImporter = TCCProfileImporter() + let resourceURL = try getResourceProfile(fileName: "TestTCCUnsignedProfile") - let resourceURL = getResourceProfile(fileName: "TestTCCUnsignedProfile") - - tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) { tccProfileResult in - switch tccProfileResult { - case .success(let tccProfile): - XCTAssertNotNil(tccProfile.content) - XCTAssertNotNil(tccProfile.content[0].services) - case .failure(let tccProfileError): - XCTFail("Unable to read tccProfile \(tccProfileError.localizedDescription)") - } - } + let tccProfile = try tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) + #expect(!tccProfile.content.isEmpty) + #expect(!tccProfile.content[0].services.isEmpty) } - func testCorrectUnsignedProfileContentDataAllLowercase() { + @Test + func correctUnsignedProfileContentDataAllLowercase() throws { let tccProfileImporter = TCCProfileImporter() + let resourceURL = try getResourceProfile(fileName: "TestTCCUnsignedProfile-allLower") - let resourceURL = getResourceProfile(fileName: "TestTCCUnsignedProfile-allLower") - - tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) { tccProfileResult in - switch tccProfileResult { - case .success(let tccProfile): - XCTAssertNotNil(tccProfile.content) - XCTAssertNotNil(tccProfile.content[0].services) - case .failure(let tccProfileError): - XCTFail("Unable to read tccProfile \(tccProfileError.localizedDescription)") - } - } + let tccProfile = try tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) + #expect(!tccProfile.content.isEmpty) + #expect(!tccProfile.content[0].services.isEmpty) } - func testBrokenUnsignedProfile() { + @Test + func brokenUnsignedProfile() throws { let tccProfileImporter = TCCProfileImporter() - - let resourceURL = getResourceProfile(fileName: "TestTCCUnsignedProfile-Broken") - + let resourceURL = try getResourceProfile(fileName: "TestTCCUnsignedProfile-Broken") let expectedTCCProfileError = TCCProfileImportError.invalidProfileFile(description: "The given data was not a valid property list.") - tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) { tccProfileResult in - switch tccProfileResult { - case .success: - XCTFail("Broken Unsigned Profile, it shouldn't be success") - case .failure(let tccProfileError): - XCTAssertEqual(tccProfileError.localizedDescription, expectedTCCProfileError.localizedDescription) - } + do { + _ = try tccProfileImporter.decodeTCCProfile(fileUrl: resourceURL) + Issue.record("Broken Unsigned Profile, it shouldn't be success") + } catch { + #expect(error.localizedDescription == expectedTCCProfileError.localizedDescription) } } - private func getResourceProfile(fileName: String) -> URL { - let testBundle = Bundle(for: type(of: self)) - guard let resourceURL = testBundle.url(forResource: fileName, withExtension: "mobileconfig") else { - XCTFail("Resource file should exists") - return URL(fileURLWithPath: "invalidPath") - } - return resourceURL + private func getResourceProfile(fileName: String) throws -> URL { + let testBundle = Bundle(for: BundleLocator.self) + return try #require(testBundle.url(forResource: fileName, withExtension: "mobileconfig")) } } diff --git a/PPPC UtilityTests/TCCProfileImporterTests/TCCProfileTests.swift b/PPPC UtilityTests/TCCProfileImporterTests/TCCProfileTests.swift index f643e93..45e45f2 100644 --- a/PPPC UtilityTests/TCCProfileImporterTests/TCCProfileTests.swift +++ b/PPPC UtilityTests/TCCProfileImporterTests/TCCProfileTests.swift @@ -23,131 +23,138 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. // -import XCTest +import Foundation +import Testing @testable import PPPC_Utility -class TCCProfileTests: XCTestCase { +private final class BundleLocator {} + +@Suite +struct TCCProfileTests { // MARK: - tests for serializing to and from xml - func testSerializationOfComplexProfileUsingAuthorization() throws { + @Test + func serializationOfComplexProfileUsingAuthorization() throws { // when we export to xml and reimport it should still have the same attributes let plistData = try TCCProfileBuilder().buildProfile(authorization: .allowStandardUserToSetSystemService).xmlData() let profile = try TCCProfile.parse(from: plistData) // then verify the config profile top level - XCTAssertEqual("Configuration", profile.type) - XCTAssertEqual(100, profile.version) - XCTAssertEqual("the uuid", profile.uuid) - XCTAssertEqual("System", profile.scope) - XCTAssertEqual("Test Org", profile.organization) - XCTAssertEqual("Test ID", profile.identifier) - XCTAssertEqual("Test Name", profile.displayName) - XCTAssertEqual("Test Desc", profile.payloadDescription) + #expect(profile.type == "Configuration") + #expect(profile.version == 100) + #expect(profile.uuid == "the uuid") + #expect(profile.scope == "System") + #expect(profile.organization == "Test Org") + #expect(profile.identifier == "Test ID") + #expect(profile.displayName == "Test Name") + #expect(profile.payloadDescription == "Test Desc") // then verify the payload content top level - XCTAssertEqual(1, profile.content.count) + #expect(profile.content.count == 1) profile.content.forEach { content in - XCTAssertEqual("Content Desc 1", content.payloadDescription) - XCTAssertEqual("Content Name 1", content.displayName) - XCTAssertEqual("Content ID 1", content.identifier) - XCTAssertEqual("Content Org 1", content.organization) - XCTAssertEqual("Content type 1", content.type) - XCTAssertEqual("Content UUID 1", content.uuid) - XCTAssertEqual(1, content.version) + #expect(content.payloadDescription == "Content Desc 1") + #expect(content.displayName == "Content Name 1") + #expect(content.identifier == "Content ID 1") + #expect(content.organization == "Content Org 1") + #expect(content.type == "Content type 1") + #expect(content.uuid == "Content UUID 1") + #expect(content.version == 1) // then verify the services key - XCTAssertEqual(2, content.services.count) + #expect(content.services.count == 2) let allFiles = content.services["SystemPolicyAllFiles"] - XCTAssertEqual(1, allFiles?.count) + #expect(allFiles?.count == 1) allFiles?.forEach { policy in - XCTAssertEqual("policy id", policy.identifier) - XCTAssertEqual("policy id type", policy.identifierType) - XCTAssertEqual("policy code req", policy.codeRequirement) - XCTAssertNil(policy.allowed) - XCTAssertEqual(TCCPolicyAuthorizationValue.allowStandardUserToSetSystemService, policy.authorization) - XCTAssertEqual("policy comment", policy.comment) - XCTAssertEqual("policy receiver id", policy.receiverIdentifier) - XCTAssertEqual("policy receiver id type", policy.receiverIdentifierType) - XCTAssertEqual("policy receiver code req", policy.receiverCodeRequirement) + #expect(policy.identifier == "policy id") + #expect(policy.identifierType == "policy id type") + #expect(policy.codeRequirement == "policy code req") + #expect(policy.allowed == nil) + #expect(policy.authorization == .allowStandardUserToSetSystemService) + #expect(policy.comment == "policy comment") + #expect(policy.receiverIdentifier == "policy receiver id") + #expect(policy.receiverIdentifierType == "policy receiver id type") + #expect(policy.receiverCodeRequirement == "policy receiver code req") } } } - func testSerializationOfProfileUsingLegacyAllowedKey() throws { + @Test + func serializationOfProfileUsingLegacyAllowedKey() throws { // when we export to xml and reimport it should still have the same attributes let plistData = try TCCProfileBuilder().buildProfile(allowed: true).xmlData() let profile = try TCCProfile.parse(from: plistData) // then verify the config profile top level - XCTAssertEqual("Configuration", profile.type) - XCTAssertEqual(100, profile.version) - XCTAssertEqual("the uuid", profile.uuid) - XCTAssertEqual("System", profile.scope) - XCTAssertEqual("Test Org", profile.organization) - XCTAssertEqual("Test ID", profile.identifier) - XCTAssertEqual("Test Name", profile.displayName) - XCTAssertEqual("Test Desc", profile.payloadDescription) + #expect(profile.type == "Configuration") + #expect(profile.version == 100) + #expect(profile.uuid == "the uuid") + #expect(profile.scope == "System") + #expect(profile.organization == "Test Org") + #expect(profile.identifier == "Test ID") + #expect(profile.displayName == "Test Name") + #expect(profile.payloadDescription == "Test Desc") // then verify the payload content top level - XCTAssertEqual(1, profile.content.count) + #expect(profile.content.count == 1) profile.content.forEach { content in - XCTAssertEqual("Content Desc 1", content.payloadDescription) - XCTAssertEqual("Content Name 1", content.displayName) - XCTAssertEqual("Content ID 1", content.identifier) - XCTAssertEqual("Content Org 1", content.organization) - XCTAssertEqual("Content type 1", content.type) - XCTAssertEqual("Content UUID 1", content.uuid) - XCTAssertEqual(1, content.version) + #expect(content.payloadDescription == "Content Desc 1") + #expect(content.displayName == "Content Name 1") + #expect(content.identifier == "Content ID 1") + #expect(content.organization == "Content Org 1") + #expect(content.type == "Content type 1") + #expect(content.uuid == "Content UUID 1") + #expect(content.version == 1) // then verify the services key - XCTAssertEqual(2, content.services.count) + #expect(content.services.count == 2) let allFiles = content.services["SystemPolicyAllFiles"] - XCTAssertEqual(1, allFiles?.count) + #expect(allFiles?.count == 1) allFiles?.forEach { policy in - XCTAssertEqual("policy id", policy.identifier) - XCTAssertEqual("policy id type", policy.identifierType) - XCTAssertEqual("policy code req", policy.codeRequirement) - XCTAssertEqual(true, policy.allowed) - XCTAssertNil(policy.authorization) - XCTAssertEqual("policy comment", policy.comment) - XCTAssertEqual("policy receiver id", policy.receiverIdentifier) - XCTAssertEqual("policy receiver id type", policy.receiverIdentifierType) - XCTAssertEqual("policy receiver code req", policy.receiverCodeRequirement) + #expect(policy.identifier == "policy id") + #expect(policy.identifierType == "policy id type") + #expect(policy.codeRequirement == "policy code req") + #expect(policy.allowed == true) + #expect(policy.authorization == nil) + #expect(policy.comment == "policy comment") + #expect(policy.receiverIdentifier == "policy receiver id") + #expect(policy.receiverIdentifierType == "policy receiver id type") + #expect(policy.receiverCodeRequirement == "policy receiver code req") } } } - func testSerializationOfProfileWhenBothAllowedAndAuthorizationUsed() throws { + @Test + func serializationOfProfileWhenBothAllowedAndAuthorizationUsed() throws { // when we export to xml and reimport it should still have the same attributes let plistData = try TCCProfileBuilder().buildProfile(allowed: false, authorization: .allow).xmlData() let profile = try TCCProfile.parse(from: plistData) // then verify the config profile top level - XCTAssertEqual("Configuration", profile.type) + #expect(profile.type == "Configuration") // then verify the payload content top level - XCTAssertEqual(1, profile.content.count) + #expect(profile.content.count == 1) profile.content.forEach { content in - XCTAssertEqual("Content UUID 1", content.uuid) - XCTAssertEqual(1, content.version) + #expect(content.uuid == "Content UUID 1") + #expect(content.version == 1) // then verify the services key - XCTAssertEqual(2, content.services.count) + #expect(content.services.count == 2) let allFiles = content.services["SystemPolicyAllFiles"] - XCTAssertEqual(1, allFiles?.count) + #expect(allFiles?.count == 1) allFiles?.forEach { policy in - XCTAssertEqual(false, policy.allowed) - XCTAssertEqual(policy.authorization, TCCPolicyAuthorizationValue.allow) + #expect(policy.allowed == false) + #expect(policy.authorization == .allow) } } } // unit tests for handling both Auth and allowed keys should fail? - func testSettingLegacyAllowValueNullifiesAuthorization() throws { - // given + @Test + func settingLegacyAllowValueNullifiesAuthorization() { var tccPolicy = TCCPolicy(identifier: "id", codeRequirement: "req", receiverIdentifier: "recId", receiverCodeRequirement: "recreq") tccPolicy.authorization = .allow @@ -155,12 +162,12 @@ class TCCProfileTests: XCTestCase { tccPolicy.allowed = true // then - XCTAssertNil(tccPolicy.authorization) - XCTAssertTrue(try XCTUnwrap(tccPolicy.allowed)) + #expect(tccPolicy.authorization == nil) + #expect(tccPolicy.allowed == true) } - func testSettingAuthorizationValueDoesNotNullifyAllowed() { - // given + @Test + func settingAuthorizationValueDoesNotNullifyAllowed() { var tccPolicy = TCCPolicy(identifier: "id", codeRequirement: "req", receiverIdentifier: "recId", receiverCodeRequirement: "recreq") tccPolicy.allowed = false @@ -168,29 +175,26 @@ class TCCProfileTests: XCTestCase { tccPolicy.authorization = .allowStandardUserToSetSystemService // then - XCTAssertEqual(tccPolicy.allowed, false, "we don't have to nil this out because we use authorization by default if present") - XCTAssertEqual(tccPolicy.authorization, TCCPolicyAuthorizationValue.allowStandardUserToSetSystemService) + #expect(tccPolicy.allowed == false, "we don't have to nil this out because we use authorization by default if present") + #expect(tccPolicy.authorization == .allowStandardUserToSetSystemService) } - func testJamfProAPIData() throws { - // given - build the test profile + @Test + func jamfProAPIData() async throws { let tccProfile = TCCProfileBuilder().buildProfile(allowed: false, authorization: .allow) let expected = try loadTextFile(fileName: "TestTCCProfileForJamfProAPI").trimmingCharacters(in: .whitespacesAndNewlines) - // when - wrap in Jamf Pro API xml - let data = try tccProfile.jamfProAPIData(signingIdentity: nil, site: nil) + // when + let data = try await tccProfile.jamfProAPIData(signingIdentity: nil, site: nil) // then let xmlString = String(data: data, encoding: .utf8) - XCTAssertEqual(xmlString, expected) + #expect(xmlString == expected) } private func loadTextFile(fileName: String) throws -> String { - let testBundle = Bundle(for: type(of: self)) - guard let resourceURL = testBundle.url(forResource: fileName, withExtension: "txt") else { - XCTFail("Resource file should exists") - return "" - } + let testBundle = Bundle(for: BundleLocator.self) + let resourceURL = try #require(testBundle.url(forResource: fileName, withExtension: "txt")) return try String(contentsOf: resourceURL) } } diff --git a/README.md b/README.md index 1ce0ce9..f183273 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,13 @@ To upload the Privacy Preferences Policy Control Payload to Jamf Pro 10.7.0 and Signed and unsigned profiles can be imported. ![Import any profile](/Images/ImportProfile.png "Import profiles") + +## Development + +### Pre-commit Hook + +A pre-commit hook is available that automatically formats Swift files using `swift-format` before each commit. To install it, run: + +```bash +ln -sf ../../git-hooks/pre-commit.swift-format .git/hooks/pre-commit +``` diff --git a/Source/AppDelegate.swift b/Source/AppDelegate.swift index ec32034..041ac5b 100644 --- a/Source/AppDelegate.swift +++ b/Source/AppDelegate.swift @@ -27,7 +27,7 @@ import Cocoa -@NSApplicationMain +@main class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ aNotification: Notification) {} diff --git a/Source/Extensions/ArrayExtensions.swift b/Source/Extensions/ArrayExtensions.swift index 8b96a40..5d5706b 100644 --- a/Source/Extensions/ArrayExtensions.swift +++ b/Source/Extensions/ArrayExtensions.swift @@ -26,6 +26,7 @@ // import Foundation + extension Array where Element: Equatable { mutating func appendIfNew(_ item: Element) { if !contains(item) { diff --git a/Source/Extensions/LoggerExtensions.swift b/Source/Extensions/LoggerExtensions.swift index b91e54d..aa7bf58 100644 --- a/Source/Extensions/LoggerExtensions.swift +++ b/Source/Extensions/LoggerExtensions.swift @@ -8,7 +8,7 @@ // This extension simplifies the logger instance creation by calling the bundle Id // and pre-declaring categories. Currently the predefined categories match the -// class name. +// class name. import OSLog diff --git a/Source/Extensions/TCCProfileExtensions.swift b/Source/Extensions/TCCProfileExtensions.swift index d60fdee..c45650f 100644 --- a/Source/Extensions/TCCProfileExtensions.swift +++ b/Source/Extensions/TCCProfileExtensions.swift @@ -27,9 +27,9 @@ import Foundation -public extension TCCProfile { +extension TCCProfile { - enum ParseError: Error { + public enum ParseError: Error { case failedToCreateDecoder } @@ -37,7 +37,7 @@ public extension TCCProfile { /// - Parameter profileData: The raw profile data (generally from a file read operation). This may be CMS encoded. /// - Returns: A ``TCCProfile`` instance. /// - Throws: Either a ``TCCProfile.ParseError`` or a `DecodingError`. - static func parse(from profileData: Data) throws -> TCCProfile { + public static func parse(from profileData: Data) throws -> TCCProfile { // The profile may be CMS encoded; let's try to decode it. guard let cmsDecoder = SwiftyCMSDecoder() else { throw ParseError.failedToCreateDecoder @@ -73,9 +73,10 @@ public extension TCCProfile { // converts it to the standard letter case. let conversionBlock = { (codingKey: CodingKey) in let requiredString = ">\(codingKey.stringValue)<" - newString = newString.replacingOccurrences(of: requiredString, - with: requiredString, - options: .caseInsensitive) + newString = newString.replacingOccurrences( + of: requiredString, + with: requiredString, + options: .caseInsensitive) } // Currently there are three model structs that are used to decode the profile. diff --git a/Source/Model/Executable.swift b/Source/Model/Executable.swift index 5eb2971..9e4cc25 100644 --- a/Source/Model/Executable.swift +++ b/Source/Model/Executable.swift @@ -75,7 +75,6 @@ class Executable: NSObject { } class Policy: NSObject { - // swiftlint:disable identifier_name @objc dynamic var AddressBook: String = "-" @objc dynamic var Calendar: String = "-" @objc dynamic var Reminders: String = "-" @@ -96,7 +95,6 @@ class Policy: NSObject { @objc dynamic var SystemPolicyDownloadsFolder: String = "-" @objc dynamic var SystemPolicyNetworkVolumes: String = "-" @objc dynamic var SystemPolicyRemovableVolumes: String = "-" - // swiftlint:enable identifier_name func allPolicyValues() -> [String] { let mirror = Mirror(reflecting: self) diff --git a/Source/Model/LoadExecutableError.swift b/Source/Model/LoadExecutableError.swift index 0234647..509942e 100644 --- a/Source/Model/LoadExecutableError.swift +++ b/Source/Model/LoadExecutableError.swift @@ -26,6 +26,7 @@ // import Foundation + public enum LoadExecutableError: Error { case identifierNotFound case resourceURLNotFound diff --git a/Source/Model/Model.swift b/Source/Model/Model.swift index 327029d..1626db3 100644 --- a/Source/Model/Model.swift +++ b/Source/Model/Model.swift @@ -28,43 +28,36 @@ import Cocoa import OSLog -@objc class Model: NSObject { +@objc class Model: NSObject, @unchecked Sendable { @objc dynamic var current: Executable? - @objc dynamic static let shared = Model() + @objc dynamic nonisolated(unsafe) static let shared: Model = { + MainActor.assumeIsolated { Model() } + }() @objc dynamic var identities: [SigningIdentity] = [] @objc dynamic var selectedExecutables: [Executable] = [] let logger = Logger.Model - func getAppleEventChoices(executable: Executable) -> [Executable] { + func getAppleEventChoices(executable: Executable) async -> [Executable] { var executables: [Executable] = [] - loadExecutable(url: URL(fileURLWithPath: "/System/Library/CoreServices/System Events.app")) { result in - switch result { - case .success(let executable): - executables.append(executable) - case .failure(let error): - self.logger.error("\(error)") - } + do { + executables.append(try await loadExecutable(url: URL(fileURLWithPath: "/System/Library/CoreServices/System Events.app"))) + } catch { + self.logger.error("\(error)") } - loadExecutable(url: URL(fileURLWithPath: "/System/Library/CoreServices/SystemUIServer.app")) { result in - switch result { - case .success(let executable): - executables.append(executable) - case .failure(let error): - self.logger.error("\(error)") - } + do { + executables.append(try await loadExecutable(url: URL(fileURLWithPath: "/System/Library/CoreServices/SystemUIServer.app"))) + } catch { + self.logger.error("\(error)") } - loadExecutable(url: URL(fileURLWithPath: "/System/Library/CoreServices/Finder.app")) { result in - switch result { - case .success(let executable): - executables.append(executable) - case .failure(let error): - self.logger.error("\(error)") - } + do { + executables.append(try await loadExecutable(url: URL(fileURLWithPath: "/System/Library/CoreServices/Finder.app"))) + } catch { + self.logger.error("\(error)") } let others = store.values.filter { $0 != executable && !Set(executables).contains($0) } @@ -87,17 +80,16 @@ struct IconFilePath { } typealias LoadExecutableResult = Result -typealias LoadExecutableCompletion = ((LoadExecutableResult) -> Void) extension Model { - func loadExecutable(url: URL, completion: @escaping LoadExecutableCompletion) { + func loadExecutable(url: URL) async throws -> Executable { let executable = Executable() if let bundle = Bundle(url: url) { switch populateFromBundle(executable, bundle: bundle, url: url) { case .failure(let error): - return completion(.failure(error)) + throw error case .success: break } @@ -106,15 +98,15 @@ extension Model { } if let alreadyFoundExecutable = store[executable.identifier] { - return completion(.success(alreadyFoundExecutable)) + return alreadyFoundExecutable } do { - executable.codeRequirement = try SecurityWrapper.copyDesignatedRequirement(url: url) + executable.codeRequirement = try await SecurityWrapper.copyDesignatedRequirement(url: url) store[executable.identifier] = executable - return completion(.success(executable)) + return executable } catch { - return completion(.failure(.codeRequirementError(description: error.localizedDescription))) + throw LoadExecutableError.codeRequirementError(description: error.localizedDescription) } } @@ -171,6 +163,7 @@ extension Model { return IconFilePath.unknown } } + } // MARK: Exporting Profile @@ -186,11 +179,11 @@ extension Model { for attr in mirroredServices.children { if let key = attr.label, let value = attr.value as? String { - if let policyToAppend = policyFromString(executable: executable, value: value) { - services[key] = services[key] ?? [] - services[key]?.append(policyToAppend) - } - } + if let policyToAppend = policyFromString(executable: executable, value: value) { + services[key] = services[key] ?? [] + services[key]?.append(policyToAppend) + } + } } executable.appleEvents.forEach { event in @@ -203,29 +196,31 @@ extension Model { } } - return TCCProfile(organization: organization, - identifier: identifier, - displayName: displayName, - payloadDescription: payloadDescription, - services: services) + return TCCProfile( + organization: organization, + identifier: identifier, + displayName: displayName, + payloadDescription: payloadDescription, + services: services) } - func importProfile(tccProfile: TCCProfile) { + func importProfile(tccProfile: TCCProfile) async { if let content = tccProfile.content.first { self.cleanUpAndRemoveDependencies() self.importedTCCProfile = tccProfile for (key, policies) in content.services { - getExecutablesFromAllPolicies(policies: policies) + await getExecutablesFromAllPolicies(policies: policies) for policy in policies { let executable = getExecutableFromSelectedExecutables(bundleIdentifier: policy.identifier) if key == ServicesKeys.appleEvents.rawValue { if let source = executable, let rIdentifier = policy.receiverIdentifier, - let rCodeRequirement = policy.receiverCodeRequirement { - let destination = getExecutableFrom(identifier: rIdentifier, codeRequirement: rCodeRequirement) + let rCodeRequirement = policy.receiverCodeRequirement + { + let destination = await getExecutableFrom(identifier: rIdentifier, codeRequirement: rCodeRequirement) let allowed: Bool = (policy.allowed == true || policy.authorization == TCCPolicyAuthorizationValue.allow) let appleEvent = AppleEventRule(source: source, destination: destination, value: allowed) executable?.appleEvents.appendIfNew(appleEvent) @@ -245,10 +240,11 @@ extension Model { } func policyFromString(executable: Executable, value: String, event: AppleEventRule? = nil) -> TCCPolicy? { - var policy = TCCPolicy(identifier: executable.identifier, - codeRequirement: executable.codeRequirement, - receiverIdentifier: event?.destination.identifier, - receiverCodeRequirement: event?.destination.codeRequirement) + var policy = TCCPolicy( + identifier: executable.identifier, + codeRequirement: executable.codeRequirement, + receiverIdentifier: event?.destination.identifier, + receiverCodeRequirement: event?.destination.codeRequirement) switch value { case TCCProfileDisplayValue.allow.rawValue: policy.authorization = .allow @@ -262,10 +258,10 @@ extension Model { return policy } - func getExecutablesFromAllPolicies(policies: [TCCPolicy]) { + func getExecutablesFromAllPolicies(policies: [TCCPolicy]) async { for tccPolicy in policies where getExecutableFromSelectedExecutables(bundleIdentifier: tccPolicy.identifier) == nil { - let executable = getExecutableFrom(identifier: tccPolicy.identifier, codeRequirement: tccPolicy.codeRequirement) - self.selectedExecutables.append(executable) + let executable = await getExecutableFrom(identifier: tccPolicy.identifier, codeRequirement: tccPolicy.codeRequirement) + self.selectedExecutables.append(executable) } } @@ -276,39 +272,29 @@ extension Model { return nil } - func getExecutableFrom(identifier: String, codeRequirement: String) -> Executable { + func getExecutableFrom(identifier: String, codeRequirement: String) async -> Executable { var executable = Executable(identifier: identifier, codeRequirement: codeRequirement) - findExecutableOnComputerUsing(bundleIdentifier: identifier) { result in - switch result { - case .success(let goodExecutable): - executable = goodExecutable - case .failure(let error): - self.logger.error("\(error)") - } + do { + executable = try await findExecutable(bundleIdentifier: identifier) + } catch { + self.logger.error("\(error)") } return executable } - private func findExecutableOnComputerUsing(bundleIdentifier: String, completion: @escaping LoadExecutableCompletion) { - var urlToLoad: URL? + private func findExecutable(bundleIdentifier: String) async throws -> Executable { + var urlToLoad: URL? if bundleIdentifier.contains("/") { - urlToLoad = URL(string: "file://\(bundleIdentifier)") + urlToLoad = URL(string: "file://\(bundleIdentifier)") } else { - urlToLoad = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + urlToLoad = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) } - if let fileURL = urlToLoad { - self.loadExecutable(url: fileURL) { result in - switch result { - case .success(let executable): - return completion(.success(executable)) - case .failure(let error): - return completion(.failure(error)) - } - } + if let fileURL = urlToLoad { + return try await self.loadExecutable(url: fileURL) } - return completion(.failure(.executableNotFound)) + throw LoadExecutableError.executableNotFound } private func cleanUpAndRemoveDependencies() { diff --git a/Source/Model/PPPCServicesManager.swift b/Source/Model/PPPCServicesManager.swift index 0f5306b..fc22a61 100644 --- a/Source/Model/PPPCServicesManager.swift +++ b/Source/Model/PPPCServicesManager.swift @@ -28,13 +28,15 @@ import Foundation import OSLog -class PPPCServicesManager { +class PPPCServicesManager: @unchecked Sendable { typealias MDMServiceKey = String let logger = Logger.PPPCServicesManager - static let shared = PPPCServicesManager() + nonisolated(unsafe) static let shared: PPPCServicesManager = { + MainActor.assumeIsolated { PPPCServicesManager() } + }() let allServices: [MDMServiceKey: PPPCServiceInfo] diff --git a/Source/Model/SigningIdentity.swift b/Source/Model/SigningIdentity.swift index 7ba021a..469412f 100644 --- a/Source/Model/SigningIdentity.swift +++ b/Source/Model/SigningIdentity.swift @@ -30,11 +30,11 @@ import Cocoa class SigningIdentity: NSObject { @objc dynamic var displayName: String - var reference: SecIdentity? + nonisolated(unsafe) var reference: SecIdentity? - init(name: String, reference: SecIdentity?) { - displayName = name - super.init() + nonisolated init(name: String, reference: SecIdentity?) { + self.displayName = name self.reference = reference + super.init() } } diff --git a/Source/Model/TCCProfile.swift b/Source/Model/TCCProfile.swift index 329228e..2c01a38 100644 --- a/Source/Model/TCCProfile.swift +++ b/Source/Model/TCCProfile.swift @@ -96,7 +96,6 @@ public struct TCCProfile: Codable { var version: Int var services: [String: [TCCPolicy]] - // swiftlint:disable:next nesting enum CodingKeys: String, CodingKey, CaseIterable { case payloadDescription = "PayloadDescription" case displayName = "PayloadDisplayName" @@ -130,14 +129,15 @@ public struct TCCProfile: Codable { case content = "PayloadContent" } init(organization: String, identifier: String, displayName: String, payloadDescription: String, services: [String: [TCCPolicy]]) { - let content = Content(payloadDescription: payloadDescription, - displayName: displayName, - identifier: identifier, - organization: organization, - type: "com.apple.TCC.configuration-profile-policy", - uuid: UUID().uuidString, - version: 1, - services: services) + let content = Content( + payloadDescription: payloadDescription, + displayName: displayName, + identifier: identifier, + organization: organization, + type: "com.apple.TCC.configuration-profile-policy", + uuid: UUID().uuidString, + version: 1, + services: services) self.version = 1 self.uuid = UUID().uuidString self.type = "Configuration" @@ -160,11 +160,11 @@ public struct TCCProfile: Codable { /// - signingIdentity: A signing identity; can be nil to leave the profile unsigned. /// - site: A Jamf Pro site /// - Returns: XML data for use with the Jamf Pro API. - func jamfProAPIData(signingIdentity: SecIdentity?, site: (String, String)?) throws -> Data { + func jamfProAPIData(signingIdentity: SecIdentity?, site: (String, String)?) async throws -> Data { var profileText: String var profileData = try xmlData() if let identity = signingIdentity { - profileData = try SecurityWrapper.sign(data: profileData, using: identity) + profileData = try await SecurityWrapper.sign(data: profileData, using: identity) } profileText = String(data: profileData, encoding: .utf8) ?? "" diff --git a/Source/Networking/JamfProAPIClient.swift b/Source/Networking/JamfProAPIClient.swift index 2d1d695..76dc3e2 100644 --- a/Source/Networking/JamfProAPIClient.swift +++ b/Source/Networking/JamfProAPIClient.swift @@ -31,45 +31,47 @@ class JamfProAPIClient: Networking { let applicationJson = "application/json" override func getBearerToken(authInfo: AuthenticationInfo) async throws -> Token { - switch authInfo { - case .basicAuth: - let endpoint = "api/v1/auth/token" - var request = try url(forEndpoint: endpoint) + switch authInfo { + case .basicAuth: + let endpoint = "api/v1/auth/token" + var request = try url(forEndpoint: endpoint) - request.httpMethod = "POST" - request.setValue(applicationJson, forHTTPHeaderField: "Accept") + request.httpMethod = "POST" + request.setValue(applicationJson, forHTTPHeaderField: "Accept") - return try await loadBasicAuthorized(request: request) - case .clientCreds(let id, let secret): - let request = try oauthTokenRequest(clientId: id, clientSecret: secret) + return try await loadBasicAuthorized(request: request) + case .clientCreds(let id, let secret): + let request = try oauthTokenRequest(clientId: id, clientSecret: secret) - return try await loadPreAuthorized(request: request) - } + return try await loadPreAuthorized(request: request) + } } - /// Creates the OAuth client credentials token request - /// - Parameters: - /// - clientId: The client ID - /// - clientSecret: The client secret - /// - Returns: A `URLRequest` that is ready to send to acquire an OAuth token. - func oauthTokenRequest(clientId: String, clientSecret: String) throws -> URLRequest { - let endpoint = "api/oauth/token" - var request = try url(forEndpoint: endpoint) + /// Creates the OAuth client credentials token request + /// - Parameters: + /// - clientId: The client ID + /// - clientSecret: The client secret + /// - Returns: A `URLRequest` that is ready to send to acquire an OAuth token. + func oauthTokenRequest(clientId: String, clientSecret: String) throws -> URLRequest { + let endpoint = "api/oauth/token" + var request = try url(forEndpoint: endpoint) - request.httpMethod = "POST" - request.setValue(applicationJson, forHTTPHeaderField: "Accept") + request.httpMethod = "POST" + request.setValue(applicationJson, forHTTPHeaderField: "Accept") - var components = URLComponents() - components.queryItems = [URLQueryItem(name: "grant_type", value: "client_credentials"), - URLQueryItem(name: "client_id", value: clientId), - URLQueryItem(name: "client_secret", value: clientSecret)] + var components = URLComponents() + components.queryItems = [ + URLQueryItem(name: "grant_type", value: "client_credentials"), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "client_secret", value: clientSecret) + ] - request.httpBody = components.percentEncodedQuery?.data(using: .utf8) + request.httpBody = components.percentEncodedQuery?.data(using: .utf8) - return request - } + return request + } - // MARK: - Requests with fallback auth + // MARK: - Requests with fallback auth /// Make a network request and decode the response using bearer auth if possible, falling back to basic auth if needed. /// - Parameter request: The `URLRequest` to make @@ -77,7 +79,7 @@ class JamfProAPIClient: Networking { func load(request: URLRequest) async throws -> T { let result: T - if await authManager.bearerAuthSupported() { + if authManager.bearerAuthSupported() { do { result = try await loadBearerAuthorized(request: request) } catch AuthError.bearerAuthNotSupported { @@ -98,7 +100,7 @@ class JamfProAPIClient: Networking { func send(request: URLRequest) async throws -> Data { let result: Data - if await authManager.bearerAuthSupported() { + if authManager.bearerAuthSupported() { do { result = try await sendBearerAuthorized(request: request) } catch AuthError.bearerAuthNotSupported { @@ -115,10 +117,10 @@ class JamfProAPIClient: Networking { // MARK: - Useful API endpoints - /// Reads the Jamf Pro organization name - /// - /// Requires "Read Activation Code" permission in the API - /// - Parameter profileData: The prepared profile data + /// Reads the Jamf Pro organization name + /// + /// Requires "Read Activation Code" permission in the API + /// - Parameter profileData: The prepared profile data func getOrganizationName() async throws -> String { let endpoint = "JSSResource/activationcode" var request = try url(forEndpoint: endpoint) @@ -132,10 +134,10 @@ class JamfProAPIClient: Networking { return info.activationCode.organizationName } - /// Gets the Jamf Pro version - /// - /// No specific permissions required. - /// - Returns: The Jamf Pro server version + /// Gets the Jamf Pro version + /// + /// No specific permissions required. + /// - Returns: The Jamf Pro server version func getJamfProVersion() async throws -> JamfProVersion { let endpoint = "api/v1/jamf-pro-version" var request = try url(forEndpoint: endpoint) @@ -156,10 +158,10 @@ class JamfProAPIClient: Networking { return info } - /// Uploads a computer configuration profile - /// - /// Requires "Create macOS Configuration Profiles" permission in the API - /// - Parameter profileData: The prepared profile data + /// Uploads a computer configuration profile + /// + /// Requires "Create macOS Configuration Profiles" permission in the API + /// - Parameter profileData: The prepared profile data func upload(computerConfigProfile profileData: Data) async throws { let endpoint = "JSSResource/osxconfigurationprofiles" var request = try url(forEndpoint: endpoint) diff --git a/Source/Networking/JamfProAPITypes.swift b/Source/Networking/JamfProAPITypes.swift index 43d4e1e..7b751ac 100644 --- a/Source/Networking/JamfProAPITypes.swift +++ b/Source/Networking/JamfProAPITypes.swift @@ -31,9 +31,10 @@ struct JamfProVersion: Decodable { let version: String func mainVersionInfo() -> String { - return String(version.prefix { character in - return (character.isNumber || character == ".") - }) + return String( + version.prefix { character in + return (character.isNumber || character == ".") + }) } func semantic() -> SemanticVersion { @@ -47,14 +48,16 @@ struct JamfProVersion: Decodable { init(fromHTMLString text: String?) throws { // we take version from HTML response body if let text = text, - let startRange = text.range(of: "? @@ -56,21 +58,22 @@ actor NetworkAuthManager { private var supportsBearerAuth = true init(username: String, password: String) { - authInfo = .basicAuth(username: username, password: password) + authInfo = .basicAuth(username: username, password: password) } - init(clientId: String, clientSecret: String) { - authInfo = .clientCreds(id: clientId, secret: clientSecret) - } + init(clientId: String, clientSecret: String) { + authInfo = .clientCreds(id: clientId, secret: clientSecret) + } - func validToken(networking: Networking) async throws -> Token { + func validToken(networking: Networking) async throws -> Token { if let task = refreshTask { // A refresh is already running; we'll use those results when ready. return try await task.value } if let token = currentToken, - token.isValid { + token.isValid + { return token } @@ -88,7 +91,7 @@ actor NetworkAuthManager { defer { refreshTask = nil } do { - let newToken = try await networking.getBearerToken(authInfo: authInfo) + let newToken = try await networking.getBearerToken(authInfo: authInfo) currentToken = newToken return newToken } catch NetworkingError.serverResponse(let responseCode, _) where responseCode == 404 { @@ -111,17 +114,18 @@ actor NetworkAuthManager { /// The default is that bearer authentication is supported. After the first network call attempting to use bearer auth, if the /// server does not actually support it this will return false. /// - Returns: True if bearer auth is supported. - func bearerAuthSupported() async -> Bool { + func bearerAuthSupported() -> Bool { return supportsBearerAuth } /// Properly encodes the username and password for use in Basic authentication. /// - /// This doesn't mutate any state and only accesses `let` constants so it doesn't need to be actor isolated. + /// This doesn't mutate any state and only accesses `let` constants so it doesn't need special isolation. /// - Returns: The encoded data string for use with Basic Auth. - nonisolated func basicAuthString() throws -> String { - guard case .basicAuth(let username, let password) = authInfo, - !username.isEmpty && !password.isEmpty else { + func basicAuthString() throws -> String { + guard case .basicAuth(let username, let password) = authInfo, + !username.isEmpty && !password.isEmpty + else { throw AuthError.invalidUsernamePassword } return Data("\(username):\(password)".utf8).base64EncodedString() diff --git a/Source/Networking/Networking.swift b/Source/Networking/Networking.swift index 2c45d10..7c6b850 100644 --- a/Source/Networking/Networking.swift +++ b/Source/Networking/Networking.swift @@ -71,27 +71,27 @@ class Networking { return URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 45.0) } - /// Sends a `URLRequest` and decodes a response to an endpoint. - /// - Parameter request: A request that already has authorization info. - /// - Returns: The result. - func loadPreAuthorized(request: URLRequest) async throws -> T { - let (data, urlResponse) = try await URLSession.shared.data(for: request) - - if let httpResponse = urlResponse as? HTTPURLResponse { - if httpResponse.statusCode == 401 { - throw AuthError.invalidUsernamePassword - } else if !(200...299).contains(httpResponse.statusCode) { - throw NetworkingError.serverResponse(httpResponse.statusCode, request.url?.absoluteString ?? "") - } - } - - let decoder = JSONDecoder() - let response = try decoder.decode(T.self, from: data) - - return response - } - - /// Sends a `URLRequest` and decodes a response to a Basic Auth protected endpoint. + /// Sends a `URLRequest` and decodes a response to an endpoint. + /// - Parameter request: A request that already has authorization info. + /// - Returns: The result. + func loadPreAuthorized(request: URLRequest) async throws -> T { + let (data, urlResponse) = try await URLSession.shared.data(for: request) + + if let httpResponse = urlResponse as? HTTPURLResponse { + if httpResponse.statusCode == 401 { + throw AuthError.invalidUsernamePassword + } else if !(200...299).contains(httpResponse.statusCode) { + throw NetworkingError.serverResponse(httpResponse.statusCode, request.url?.absoluteString ?? "") + } + } + + let decoder = JSONDecoder() + let response = try decoder.decode(T.self, from: data) + + return response + } + + /// Sends a `URLRequest` and decodes a response to a Basic Auth protected endpoint. /// - Parameter request: A request that does not yet include authorization info. /// - Returns: The result. func loadBasicAuthorized(request: URLRequest) async throws -> T { diff --git a/Source/Networking/Token.swift b/Source/Networking/Token.swift index 3501fe9..12b68b9 100644 --- a/Source/Networking/Token.swift +++ b/Source/Networking/Token.swift @@ -13,49 +13,49 @@ import Foundation /// and the older basic-auth-based flow. struct Token: Decodable { let value: String - let expiresAt: Date? + let expiresAt: Date? var isValid: Bool { - if let expiration = expiresAt { - return expiration > Date() - } + if let expiration = expiresAt { + return expiration > Date() + } - return true + return true } - enum OAuthTokenCodingKeys: String, CodingKey { - case value = "access_token" - case expire = "expires_in" - } + enum OAuthTokenCodingKeys: String, CodingKey { + case value = "access_token" + case expire = "expires_in" + } - enum BasicAuthCodingKeys: String, CodingKey { + enum BasicAuthCodingKeys: String, CodingKey { case value = "token" case expireTime = "expires" } - init(from decoder: Decoder) throws { - // First try to decode with oauth client credentials token response - let container = try decoder.container(keyedBy: OAuthTokenCodingKeys.self) - let possibleValue = try? container.decode(String.self, forKey: .value) - if let value = possibleValue { - self.value = value - let expireIn = try container.decode(Double.self, forKey: .expire) - self.expiresAt = Date().addingTimeInterval(expireIn) - return - } - - // If that fails try to decode with basic auth token response - let container1 = try decoder.container(keyedBy: BasicAuthCodingKeys.self) - self.value = try container1.decode(String.self, forKey: .value) - let expireTime = try container1.decode(String.self, forKey: .expireTime) - - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - self.expiresAt = formatter.date(from: expireTime) - } - - init(value: String, expiresAt: Date) { - self.value = value - self.expiresAt = expiresAt - } + init(from decoder: Decoder) throws { + // First try to decode with oauth client credentials token response + let container = try decoder.container(keyedBy: OAuthTokenCodingKeys.self) + let possibleValue = try? container.decode(String.self, forKey: .value) + if let value = possibleValue { + self.value = value + let expireIn = try container.decode(Double.self, forKey: .expire) + self.expiresAt = Date().addingTimeInterval(expireIn) + return + } + + // If that fails try to decode with basic auth token response + let container1 = try decoder.container(keyedBy: BasicAuthCodingKeys.self) + self.value = try container1.decode(String.self, forKey: .value) + let expireTime = try container1.decode(String.self, forKey: .expireTime) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.expiresAt = formatter.date(from: expireTime) + } + + init(value: String, expiresAt: Date) { + self.value = value + self.expiresAt = expiresAt + } } diff --git a/Source/Networking/UploadManager.swift b/Source/Networking/UploadManager.swift index e7d3246..109f8c2 100644 --- a/Source/Networking/UploadManager.swift +++ b/Source/Networking/UploadManager.swift @@ -10,74 +10,56 @@ import Foundation import OSLog struct UploadManager { - let serverURL: String + let serverURL: String let logger = Logger.UploadManager - struct VerificationInfo { - let mustSign: Bool - let organization: String - } + struct VerificationInfo { + let mustSign: Bool + let organization: String + } - enum VerificationError: Error { - case anyError(String) - } + enum VerificationError: Error { + case anyError(String) + } - func verifyConnection(authManager: NetworkAuthManager, completionHandler: @escaping (Result) -> Void) { + func verifyConnection(authManager: NetworkAuthManager) async throws -> VerificationInfo { logger.info("Checking connection to Jamf Pro server") - Task { - let networking = JamfProAPIClient(serverUrlString: serverURL, tokenManager: authManager) - let result: Result + let networking = JamfProAPIClient(serverUrlString: serverURL, tokenManager: authManager) - do { - let version = try await networking.getJamfProVersion() + do { + let version = try await networking.getJamfProVersion() - // Must sign if Jamf Pro is less than v10.7.1 - let mustSign = (version.semantic() < SemanticVersion(major: 10, minor: 7, patch: 1)) + // Must sign if Jamf Pro is less than v10.7.1 + let mustSign = (version.semantic() < SemanticVersion(major: 10, minor: 7, patch: 1)) - let orgName = try await networking.getOrganizationName() + let orgName = try await networking.getOrganizationName() - result = .success(VerificationInfo(mustSign: mustSign, organization: orgName)) - } catch is AuthError { - logger.error("Invalid credentials.") - result = .failure(VerificationError.anyError("Invalid credentials.")) - } catch { - logger.error("Jamf Pro server is unavailable.") - result = .failure(VerificationError.anyError("Jamf Pro server is unavailable.")) - } + return VerificationInfo(mustSign: mustSign, organization: orgName) + } catch is AuthError { + logger.error("Invalid credentials.") + throw VerificationError.anyError("Invalid credentials.") + } catch { + logger.error("Jamf Pro server is unavailable.") + throw VerificationError.anyError("Jamf Pro server is unavailable.") + } + } - completionHandler(result) - } - } - - func upload(profile: TCCProfile, authMgr: NetworkAuthManager, siteInfo: (String, String)?, signingIdentity: SigningIdentity?, completionHandler: @escaping (Error?) -> Void) { + func upload(profile: TCCProfile, authMgr: NetworkAuthManager, siteInfo: (String, String)?, signingIdentity: SigningIdentity?) async throws { logger.info("Uploading profile: \(profile.displayName, privacy: .public)") - let networking = JamfProAPIClient(serverUrlString: serverURL, tokenManager: authMgr) - Task { - let success: Error? - var identity: SecIdentity? - if let signingIdentity = signingIdentity { - logger.info("Signing profile with \(signingIdentity.displayName)") - identity = signingIdentity.reference - } - - do { - let profileData = try profile.jamfProAPIData(signingIdentity: identity, site: siteInfo) - - _ = try await networking.upload(computerConfigProfile: profileData) - - success = nil - logger.info("Uploaded successfully") - } catch { - logger.error("Error creating or uploading profile: \(error.localizedDescription)") - success = error - } - - DispatchQueue.main.async { - completionHandler(success) - } - } - } + let networking = JamfProAPIClient(serverUrlString: serverURL, tokenManager: authMgr) + var identity: SecIdentity? + if let signingIdentity = signingIdentity { + logger.info("Signing profile with \(signingIdentity.displayName)") + identity = signingIdentity.reference + } + + let profileData = try await profile.jamfProAPIData(signingIdentity: identity, site: siteInfo) + + _ = try await networking.upload(computerConfigProfile: profileData) + + logger.info("Uploaded successfully") + } } diff --git a/Source/SecurityWrapper.swift b/Source/SecurityWrapper.swift index ad83621..7fd0a42 100644 --- a/Source/SecurityWrapper.swift +++ b/Source/SecurityWrapper.swift @@ -27,10 +27,13 @@ import Foundation import Haversack +import Security + +extension SecIdentity: @unchecked @retroactive Sendable {} struct SecurityWrapper { - static func execute(block: () -> (OSStatus)) throws { + nonisolated static func execute(block: () -> (OSStatus)) throws { let status = block() if status != 0 { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil) @@ -38,39 +41,40 @@ struct SecurityWrapper { } static func saveCredentials(username: String, password: String, server: String) throws { - let haversack = Haversack() - let item = InternetPasswordEntity() - item.server = server - item.account = username - item.passwordData = password.data(using: .utf8) + let haversack = Haversack() + let item = InternetPasswordEntity() + item.server = server + item.account = username + item.passwordData = password.data(using: .utf8) - try haversack.save(item, itemSecurity: .standard, updateExisting: true) + try haversack.save(item, itemSecurity: .standard, updateExisting: true) } static func removeCredentials(server: String, username: String) throws { - let haversack = Haversack() - let query = InternetPasswordQuery(server: server) - .matching(account: username) + let haversack = Haversack() + let query = InternetPasswordQuery(server: server) + .matching(account: username) - try haversack.delete(where: query, treatNotFoundAsSuccess: true) + try haversack.delete(where: query, treatNotFoundAsSuccess: true) } static func loadCredentials(server: String) throws -> (username: String, password: String)? { - let haversack = Haversack() - let query = InternetPasswordQuery(server: server) - .returning([.attributes, .data]) - - if let item = try? haversack.first(where: query), - let username = item.account, - let passwordData = item.passwordData, - let password = String(data: passwordData, encoding: .utf8) { - return (username: username, password: password) - } - - return nil + let haversack = Haversack() + let query = InternetPasswordQuery(server: server) + .returning([.attributes, .data]) + + if let item = try? haversack.first(where: query), + let username = item.account, + let passwordData = item.passwordData, + let password = String(data: passwordData, encoding: .utf8) + { + return (username: username, password: password) + } + + return nil } - static func copyDesignatedRequirement(url: URL) throws -> String { + @concurrent static func copyDesignatedRequirement(url: URL) async throws -> String { let flags = SecCSFlags(rawValue: 0) var staticCode: SecStaticCode? var requirement: SecRequirement? @@ -83,7 +87,7 @@ struct SecurityWrapper { return text! as String } - static func sign(data: Data, using identity: SecIdentity) throws -> Data { + @concurrent static func sign(data: Data, using identity: SecIdentity) async throws -> Data { var outputData: CFData? var encoder: CMSEncoder? @@ -96,24 +100,25 @@ struct SecurityWrapper { return outputData! as Data } - static func loadSigningIdentities() throws -> [SigningIdentity] { - let haversack = Haversack() - let query = IdentityQuery().matching(mustBeValidOnDate: Date()).returning(.reference) + @concurrent static func loadSigningIdentities() async throws -> [SigningIdentity] { + let haversack = Haversack() + let query = IdentityQuery().matching(mustBeValidOnDate: Date()).returning(.reference) - let identities = try haversack.search(where: query) + let identities = try await haversack.search(where: query) - return identities.compactMap { - guard let secIdentity = $0.reference else { - return nil - } + return identities.compactMap { + guard let secIdentity = $0.reference else { + return nil + } - let name = try? getCertificateCommonName(for: secIdentity) - return SigningIdentity(name: name ?? "Unknown \(secIdentity.hashValue)", - reference: secIdentity) - } + let name = try? getCertificateCommonName(for: secIdentity) + return SigningIdentity( + name: name ?? "Unknown \(secIdentity.hashValue)", + reference: secIdentity) + } } - static func getCertificateCommonName(for identity: SecIdentity) throws -> String { + nonisolated static func getCertificateCommonName(for identity: SecIdentity) throws -> String { var certificate: SecCertificate? var commonName: CFString? try execute { SecIdentityCopyCertificate(identity, &certificate) } diff --git a/Source/SwiftUI/UploadInfoView.swift b/Source/SwiftUI/UploadInfoView.swift index 18cdc98..19de944 100644 --- a/Source/SwiftUI/UploadInfoView.swift +++ b/Source/SwiftUI/UploadInfoView.swift @@ -9,351 +9,357 @@ import OSLog import SwiftUI struct UploadInfoView: View { - /// The signing identities available to be used. - let signingIdentities: [SigningIdentity] - /// Function to call when this view needs to be removed - let dismissAction: (() -> Void)? - - // Communicate this info to the user - @State private var warningInfo: String? - @State private var networkOperationInfo: String? - /// Must sign the profile if Jamf Pro is less than v10.7.1 - @State private var mustSign = false - /// The hash of connection info that has been verified with a succesful connection - @State private var verifiedConnectionHash: Int = 0 - - // MARK: User entry fields - @AppStorage("jamfProServer") private var serverURL = "https://" - @AppStorage("organization") private var organization = "" - @AppStorage("authType") private var authType = AuthenticationType.clientCredentials - - @State private var username = "" - @State private var password = "" - @State private var saveToKeychain: Bool = true - @State private var payloadName = "" - @State private var payloadId = UUID().uuidString - @State private var payloadDescription = "" - @State private var signingId: SigningIdentity? - @State private var useSite: Bool = false - @State private var siteId: Int = -1 - @State private var siteName: String = "" - - let logger = Logger.UploadInfoView - - /// The type of authentication the user wants to use. - /// - /// `String` type so it can be saved with `@AppStorage` above - enum AuthenticationType: String { - case basicAuth - case clientCredentials - } - - let intFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .none - return formatter - }() - - var body: some View { - VStack { - Form { - TextField("Jamf Pro Server *:", text: $serverURL) - Picker("Authorization Type:", selection: $authType) { - Text("Basic/Bearer Auth").tag(AuthenticationType.basicAuth) - Text("Client Credentials (v10.49+):").tag(AuthenticationType.clientCredentials) - } - TextField(authType == .basicAuth ? "Username *:" : "Client ID *:", text: $username) - SecureField(authType == .basicAuth ? "Password *:" : "Client Secret *:", text: $password) - - HStack { - Toggle("Save in Keychain", isOn: $saveToKeychain) - .help("Store the username & password or client id & secret in the login keychain") - if verifiedConnection { - Spacer() - Text("✔️ Verified") - .font(.footnote) - } - } - Divider() - .padding(.vertical) - - TextField("Organization *:", text: $organization) - TextField("Payload Name *:", text: $payloadName) - TextField("Payload Identifier *:", text: $payloadId) - TextField("Payload Description:", text: $payloadDescription) - Picker("Signing Identity:", selection: $signingId) { - Text("Profile signed by server").tag(nil as SigningIdentity?) - ForEach(signingIdentities, id: \.self) { identity in - Text(identity.displayName).tag(identity) - } - } - .disabled(!mustSign) - Toggle("Use Site", isOn: $useSite) - TextField("Site ID", value: $siteId, formatter: intFormatter) - .disabled(!useSite) - TextField("Site Name", text: $siteName) - .disabled(!useSite) - } - .padding(.bottom) - - if let warning = warningInfo { - Text(warning) - .font(.headline) - .foregroundColor(.red) - } - if let networkInfo = networkOperationInfo { - HStack { - Text(networkInfo) - .font(.headline) - ProgressView() - .padding(.leading) - } - } - - HStack { - Spacer() - - Button("Cancel") { - dismissView() - } - .keyboardShortcut(.cancelAction) - - Button(verifiedConnection ? "Upload" : "Check connection") { - if verifiedConnection { - performUpload() - } else { - verifyConnection() - } - } - .keyboardShortcut(.defaultAction) - .disabled(!buttonEnabled()) - } - } - .padding() - .frame(minWidth: 450) - .background(Color(.windowBackgroundColor)) - .onAppear { - // Load keychain values - if let creds = try? SecurityWrapper.loadCredentials(server: serverURL) { - username = creds.username - password = creds.password - } - - // Use model payload values if it was imported - if let tccProfile = Model.shared.importedTCCProfile { - organization = tccProfile.organization - payloadName = tccProfile.displayName - payloadDescription = tccProfile.payloadDescription - payloadId = tccProfile.identifier - } - } - } - - /// Creates a hash of the currently entered connection info - var hashOfConnectionInfo: Int { - var hasher = Hasher() - hasher.combine(serverURL) - hasher.combine(username) - hasher.combine(password) - hasher.combine(authType) - return hasher.finalize() - } - - /// Compare the last verified connection hash with the current hash of connection info - var verifiedConnection: Bool { - verifiedConnectionHash == hashOfConnectionInfo - } - - func buttonEnabled() -> Bool { - if verifiedConnection { - return payloadInfoPassesValidation() - } - return connectionInfoPassesValidation() - } - - private func warning(_ info: StaticString, shouldDisplay: Bool) { - if shouldDisplay { + /// The signing identities available to be used. + let signingIdentities: [SigningIdentity] + /// Function to call when this view needs to be removed + let dismissAction: (() -> Void)? + + // Communicate this info to the user + @State private var warningInfo: String? + @State private var networkOperationInfo: String? + /// Must sign the profile if Jamf Pro is less than v10.7.1 + @State private var mustSign = false + /// The hash of connection info that has been verified with a succesful connection + @State private var verifiedConnectionHash: Int = 0 + + // MARK: User entry fields + @AppStorage("jamfProServer") private var serverURL = "https://" + @AppStorage("organization") private var organization = "" + @AppStorage("authType") private var authType = AuthenticationType.clientCredentials + + @State private var username = "" + @State private var password = "" + @State private var saveToKeychain: Bool = true + @State private var payloadName = "" + @State private var payloadId = UUID().uuidString + @State private var payloadDescription = "" + @State private var signingId: SigningIdentity? + @State private var useSite: Bool = false + @State private var siteId: Int = -1 + @State private var siteName: String = "" + + let logger = Logger.UploadInfoView + + /// The type of authentication the user wants to use. + /// + /// `String` type so it can be saved with `@AppStorage` above + enum AuthenticationType: String { + case basicAuth + case clientCredentials + } + + let intFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .none + return formatter + }() + + var body: some View { + VStack { + Form { + TextField("Jamf Pro Server *:", text: $serverURL) + Picker("Authorization Type:", selection: $authType) { + Text("Basic/Bearer Auth").tag(AuthenticationType.basicAuth) + Text("Client Credentials (v10.49+):").tag(AuthenticationType.clientCredentials) + } + TextField(authType == .basicAuth ? "Username *:" : "Client ID *:", text: $username) + SecureField(authType == .basicAuth ? "Password *:" : "Client Secret *:", text: $password) + + HStack { + Toggle("Save in Keychain", isOn: $saveToKeychain) + .help("Store the username & password or client id & secret in the login keychain") + if verifiedConnection { + Spacer() + Text("✔️ Verified") + .font(.footnote) + } + } + Divider() + .padding(.vertical) + + TextField("Organization *:", text: $organization) + TextField("Payload Name *:", text: $payloadName) + TextField("Payload Identifier *:", text: $payloadId) + TextField("Payload Description:", text: $payloadDescription) + Picker("Signing Identity:", selection: $signingId) { + Text("Profile signed by server").tag(nil as SigningIdentity?) + ForEach(signingIdentities, id: \.self) { identity in + Text(identity.displayName).tag(identity) + } + } + .disabled(!mustSign) + Toggle("Use Site", isOn: $useSite) + TextField("Site ID", value: $siteId, formatter: intFormatter) + .disabled(!useSite) + TextField("Site Name", text: $siteName) + .disabled(!useSite) + } + .padding(.bottom) + + if let warning = warningInfo { + Text(warning) + .font(.headline) + .foregroundColor(.red) + } + if let networkInfo = networkOperationInfo { + HStack { + Text(networkInfo) + .font(.headline) + ProgressView() + .padding(.leading) + } + } + + HStack { + Spacer() + + Button("Cancel") { + dismissView() + } + .keyboardShortcut(.cancelAction) + + Button(verifiedConnection ? "Upload" : "Check connection") { + Task { + if verifiedConnection { + await performUpload() + } else { + await verifyConnection() + } + } + } + .keyboardShortcut(.defaultAction) + .disabled(!buttonEnabled()) + } + } + .padding() + .frame(minWidth: 450) + .background(Color(.windowBackgroundColor)) + .onAppear { + // Load keychain values + if let creds = try? SecurityWrapper.loadCredentials(server: serverURL) { + username = creds.username + password = creds.password + } + + // Use model payload values if it was imported + if let tccProfile = Model.shared.importedTCCProfile { + organization = tccProfile.organization + payloadName = tccProfile.displayName + payloadDescription = tccProfile.payloadDescription + payloadId = tccProfile.identifier + } + } + } + + /// Creates a hash of the currently entered connection info + var hashOfConnectionInfo: Int { + var hasher = Hasher() + hasher.combine(serverURL) + hasher.combine(username) + hasher.combine(password) + hasher.combine(authType) + return hasher.finalize() + } + + /// Compare the last verified connection hash with the current hash of connection info + var verifiedConnection: Bool { + verifiedConnectionHash == hashOfConnectionInfo + } + + func buttonEnabled() -> Bool { + if verifiedConnection { + return payloadInfoPassesValidation() + } + return connectionInfoPassesValidation() + } + + private func warning(_ info: StaticString, shouldDisplay: Bool) { + if shouldDisplay { logger.info("\(info)") - warningInfo = "\(info)" - } - } - - /// Does some simple validation of the user-entered connection info - /// - /// The `setWarningInfo` parameter is optional, and should only be set to `true` during - /// actions triggered by the user. This function can be called with `false` (or no parameters) - /// from SwiftUI's `body` function to enable/disable controls. - /// - Parameter setWarningInfo: Whether to set the warning text so the user knows something needs to be updated. Default is `false`. - /// - Returns: True if the user entered connection info passes simple local validation - func connectionInfoPassesValidation(setWarningInfo: Bool = false) -> Bool { - guard !serverURL.isEmpty else { - warning("Server URL not set", shouldDisplay: setWarningInfo) - // Future on macOS 12+: focus on serverURL field - return false - } - - guard let url = URL(string: serverURL), - url.scheme == "http" || url.scheme == "https" else { - warning("Invalid Jamf Pro Server URL", shouldDisplay: setWarningInfo) - // Future on macOS 12+: focus on serverURL field - return false - } - - if authType == .basicAuth { - guard !username.isEmpty, !password.isEmpty else { - warning("Username or password not set", shouldDisplay: setWarningInfo) - // Future on macOS 12+: focus on username or password field - return false - } - - guard username.firstIndex(of: ":") == nil else { - warning("Username cannot contain a colon", shouldDisplay: setWarningInfo) - // Future on macOS 12+: focus on username field - return false - } - } else { - guard !username.isEmpty, !password.isEmpty else { - warning("Client ID or secret not set", shouldDisplay: setWarningInfo) - // Future on macOS 12+: focus on username or password field - return false - } - } - - if setWarningInfo { - warningInfo = nil - } - return true - } - - /// Does some simple validation of the user-entered payload info - /// - /// The `setWarningInfo` parameter is optional, and should only be set to `true` during - /// actions triggered by the user. This function can be called with `false` (or no parameters) - /// from SwiftUI's `body` function to enable/disable controls. - /// - Parameter setWarningInfo: Whether to set the warning text so the user knows something needs to be updated. Default is `false`. - /// - Returns: True if the user entered payload info passes simple local validation - func payloadInfoPassesValidation(setWarningInfo: Bool = false) -> Bool { - guard !organization.isEmpty else { - warning("Must provide an organization name", shouldDisplay: setWarningInfo) - // Future on macOS 12+: focus on organization field - return false - } - - guard !payloadId.isEmpty else { - warning("Must provide a payload identifier", shouldDisplay: setWarningInfo) - // Future on macOS 12+: focus on payload ID field - return false - } - - guard !payloadName.isEmpty else { - warning("Must provide a payload name", shouldDisplay: setWarningInfo) - // Future on macOS 12+: focus on payloadName field - return false - } - - guard useSite == false || (useSite == true && siteId != -1 && !siteName.isEmpty) else { - warning("Must provide both an ID and name for the site", shouldDisplay: setWarningInfo) - // Future on macOS 12+: focus on siteId or siteName field - return false - } - - if setWarningInfo { - warningInfo = nil - } - return true - } - - func makeAuthManager() -> NetworkAuthManager { - if authType == .basicAuth { - return NetworkAuthManager(username: username, password: password) - } - - return NetworkAuthManager(clientId: username, clientSecret: password) - } - - func verifyConnection() { - guard connectionInfoPassesValidation(setWarningInfo: true) else { - return - } - - networkOperationInfo = "Checking Jamf Pro server" - - let uploadMgr = UploadManager(serverURL: serverURL) - uploadMgr.verifyConnection(authManager: makeAuthManager()) { result in - if case .success(let success) = result { - mustSign = success.mustSign - organization = success.organization - verifiedConnectionHash = hashOfConnectionInfo - if saveToKeychain { - do { - try SecurityWrapper.saveCredentials(username: username, - password: password, - server: serverURL) - } catch { - logger.error("Failed to save credentials with error: \(error.localizedDescription)") - } - } - // Future on macOS 12+: focus on Payload Name field - } else if case .failure(let failure) = result, - case .anyError(let errorString) = failure { - warningInfo = errorString - verifiedConnectionHash = 0 - } - - networkOperationInfo = nil - } - } - - private func dismissView() { - if !saveToKeychain { - try? SecurityWrapper.removeCredentials(server: serverURL, username: username) - } - - if let dismiss = dismissAction { - dismiss() - } - } - - func performUpload() { - guard connectionInfoPassesValidation(setWarningInfo: true) else { - return - } - - guard payloadInfoPassesValidation(setWarningInfo: true) else { - return - } - - let profile = Model.shared.exportProfile(organization: organization, - identifier: payloadId, - displayName: payloadName, - payloadDescription: payloadDescription) - - networkOperationInfo = "Uploading '\(profile.displayName)'..." - - var siteIdAndName: (String, String)? - if useSite { - if siteId != -1 && !siteName.isEmpty { - siteIdAndName = ("\(siteId)", siteName) - } - } - - let uploadMgr = UploadManager(serverURL: serverURL) - uploadMgr.upload(profile: profile, - authMgr: makeAuthManager(), - siteInfo: siteIdAndName, - signingIdentity: mustSign ? signingId : nil) { possibleError in - if let error = possibleError { - warningInfo = error.localizedDescription - } else { - Alert().display(header: "Success", message: "Profile uploaded succesfully") - dismissView() - } - networkOperationInfo = nil - } - } + warningInfo = "\(info)" + } + } + + /// Does some simple validation of the user-entered connection info + /// + /// The `setWarningInfo` parameter is optional, and should only be set to `true` during + /// actions triggered by the user. This function can be called with `false` (or no parameters) + /// from SwiftUI's `body` function to enable/disable controls. + /// - Parameter setWarningInfo: Whether to set the warning text so the user knows something needs to be updated. Default is `false`. + /// - Returns: True if the user entered connection info passes simple local validation + func connectionInfoPassesValidation(setWarningInfo: Bool = false) -> Bool { + guard !serverURL.isEmpty else { + warning("Server URL not set", shouldDisplay: setWarningInfo) + // Future on macOS 12+: focus on serverURL field + return false + } + + guard let url = URL(string: serverURL), + url.scheme == "http" || url.scheme == "https" + else { + warning("Invalid Jamf Pro Server URL", shouldDisplay: setWarningInfo) + // Future on macOS 12+: focus on serverURL field + return false + } + + if authType == .basicAuth { + guard !username.isEmpty, !password.isEmpty else { + warning("Username or password not set", shouldDisplay: setWarningInfo) + // Future on macOS 12+: focus on username or password field + return false + } + + guard username.firstIndex(of: ":") == nil else { + warning("Username cannot contain a colon", shouldDisplay: setWarningInfo) + // Future on macOS 12+: focus on username field + return false + } + } else { + guard !username.isEmpty, !password.isEmpty else { + warning("Client ID or secret not set", shouldDisplay: setWarningInfo) + // Future on macOS 12+: focus on username or password field + return false + } + } + + if setWarningInfo { + warningInfo = nil + } + return true + } + + /// Does some simple validation of the user-entered payload info + /// + /// The `setWarningInfo` parameter is optional, and should only be set to `true` during + /// actions triggered by the user. This function can be called with `false` (or no parameters) + /// from SwiftUI's `body` function to enable/disable controls. + /// - Parameter setWarningInfo: Whether to set the warning text so the user knows something needs to be updated. Default is `false`. + /// - Returns: True if the user entered payload info passes simple local validation + func payloadInfoPassesValidation(setWarningInfo: Bool = false) -> Bool { + guard !organization.isEmpty else { + warning("Must provide an organization name", shouldDisplay: setWarningInfo) + // Future on macOS 12+: focus on organization field + return false + } + + guard !payloadId.isEmpty else { + warning("Must provide a payload identifier", shouldDisplay: setWarningInfo) + // Future on macOS 12+: focus on payload ID field + return false + } + + guard !payloadName.isEmpty else { + warning("Must provide a payload name", shouldDisplay: setWarningInfo) + // Future on macOS 12+: focus on payloadName field + return false + } + + guard useSite == false || (useSite == true && siteId != -1 && !siteName.isEmpty) else { + warning("Must provide both an ID and name for the site", shouldDisplay: setWarningInfo) + // Future on macOS 12+: focus on siteId or siteName field + return false + } + + if setWarningInfo { + warningInfo = nil + } + return true + } + + func makeAuthManager() -> NetworkAuthManager { + if authType == .basicAuth { + return NetworkAuthManager(username: username, password: password) + } + + return NetworkAuthManager(clientId: username, clientSecret: password) + } + + func verifyConnection() async { + guard connectionInfoPassesValidation(setWarningInfo: true) else { + return + } + + networkOperationInfo = "Checking Jamf Pro server" + + let uploadMgr = UploadManager(serverURL: serverURL) + do { + let info = try await uploadMgr.verifyConnection(authManager: makeAuthManager()) + mustSign = info.mustSign + organization = info.organization + verifiedConnectionHash = hashOfConnectionInfo + if saveToKeychain { + do { + try SecurityWrapper.saveCredentials( + username: username, + password: password, + server: serverURL) + } catch { + logger.error("Failed to save credentials with error: \(error.localizedDescription)") + } + } + } catch UploadManager.VerificationError.anyError(let errorString) { + warningInfo = errorString + verifiedConnectionHash = 0 + } catch { + warningInfo = error.localizedDescription + verifiedConnectionHash = 0 + } + + networkOperationInfo = nil + } + + private func dismissView() { + if !saveToKeychain { + try? SecurityWrapper.removeCredentials(server: serverURL, username: username) + } + + if let dismiss = dismissAction { + dismiss() + } + } + + func performUpload() async { + guard connectionInfoPassesValidation(setWarningInfo: true) else { + return + } + + guard payloadInfoPassesValidation(setWarningInfo: true) else { + return + } + + let profile = Model.shared.exportProfile( + organization: organization, + identifier: payloadId, + displayName: payloadName, + payloadDescription: payloadDescription) + + networkOperationInfo = "Uploading '\(profile.displayName)'..." + + var siteIdAndName: (String, String)? + if useSite { + if siteId != -1 && !siteName.isEmpty { + siteIdAndName = ("\(siteId)", siteName) + } + } + + let uploadMgr = UploadManager(serverURL: serverURL) + do { + try await uploadMgr.upload( + profile: profile, + authMgr: makeAuthManager(), + siteInfo: siteIdAndName, + signingIdentity: mustSign ? signingId : nil) + Alert().display(header: "Success", message: "Profile uploaded succesfully") + dismissView() + } catch { + warningInfo = error.localizedDescription + } + networkOperationInfo = nil + } } #Preview { - UploadInfoView(signingIdentities: [], - dismissAction: nil) + UploadInfoView( + signingIdentities: [], + dismissAction: nil) } diff --git a/Source/TCCProfileImporter/TCCProfileConfigurationPanel.swift b/Source/TCCProfileImporter/TCCProfileConfigurationPanel.swift index c9fc3f6..e46f02b 100644 --- a/Source/TCCProfileImporter/TCCProfileConfigurationPanel.swift +++ b/Source/TCCProfileImporter/TCCProfileConfigurationPanel.swift @@ -30,30 +30,27 @@ import Foundation class TCCProfileConfigurationPanel { /// Load TCC Profile data from file - /// - /// - Parameter completion: TCCProfileImportCompletion - success with TCCProfile or failure with TCCProfileImport Error - func loadTCCProfileFromFile(importer: TCCProfileImporter, window: NSWindow, _ completion: @escaping TCCProfileImportCompletion) { - let openPanel = NSOpenPanel.init() - openPanel.allowedFileTypes = ["mobileconfig", "plist"] - openPanel.allowsMultipleSelection = false - openPanel.canChooseDirectories = false - openPanel.canCreateDirectories = false - openPanel.canChooseFiles = true - openPanel.title = "Open TCCProfile File" + /// + /// - Parameters: + /// - importer: The TCCProfileImporter to use + /// - window: The window to present the open panel in + /// - Returns: The decoded TCCProfile, or nil if the user cancelled + func loadTCCProfileFromFile(importer: TCCProfileImporter, window: NSWindow) async throws -> TCCProfile? { + let openPanel = NSOpenPanel() + openPanel.allowedFileTypes = ["mobileconfig", "plist"] + openPanel.allowsMultipleSelection = false + openPanel.canChooseDirectories = false + openPanel.canCreateDirectories = false + openPanel.canChooseFiles = true + openPanel.title = "Open TCCProfile File" - openPanel.beginSheetModal(for: window) { (response) in - if response != .OK { - completion(.failure(.cancelled)) - } else { - if let result = openPanel.url { - importer.decodeTCCProfile(fileUrl: result) { tccProfileResult in - return completion(tccProfileResult) - } - } else { - completion(.failure(TCCProfileImportError.unableToOpenFile)) - } - } - } + let response = await openPanel.beginSheetModal(for: window) + guard response == .OK else { return nil } - } + guard let fileUrl = openPanel.url else { + throw TCCProfileImportError.unableToOpenFile + } + + return try importer.decodeTCCProfile(fileUrl: fileUrl) + } } diff --git a/Source/TCCProfileImporter/TCCProfileImportError.swift b/Source/TCCProfileImporter/TCCProfileImportError.swift index 7f1c383..53f40e3 100644 --- a/Source/TCCProfileImporter/TCCProfileImportError.swift +++ b/Source/TCCProfileImporter/TCCProfileImportError.swift @@ -26,6 +26,7 @@ // import Foundation + public enum TCCProfileImportError: Error { case cancelled case unableToOpenFile diff --git a/Source/TCCProfileImporter/TCCProfileImporter.swift b/Source/TCCProfileImporter/TCCProfileImporter.swift index 0ac1111..6b61f49 100644 --- a/Source/TCCProfileImporter/TCCProfileImporter.swift +++ b/Source/TCCProfileImporter/TCCProfileImporter.swift @@ -27,46 +27,43 @@ import Foundation -typealias TCCProfileImportResult = Result -typealias TCCProfileImportCompletion = ((TCCProfileImportResult) -> Void) - /// Load tcc profiles public class TCCProfileImporter { // MARK: Load TCCProfile - /// Mapping & Decoding tcc profile + /// Mapping & Decoding tcc profile from data /// - /// - Parameter fileUrl: path with a file to load, completion: TCCProfileImportCompletion - success with TCCProfile or failure with TCCProfileImport Error - func decodeTCCProfile(data: Data, _ completion: @escaping TCCProfileImportCompletion) { + /// - Parameter data: The raw data to decode + /// - Returns: The decoded TCCProfile + func decodeTCCProfile(data: Data) throws -> TCCProfile { do { // Note that parse will ignore the signing portion of the data - let tccProfile = try TCCProfile.parse(from: data) - return completion(.success(tccProfile)) + return try TCCProfile.parse(from: data) } catch TCCProfile.ParseError.failedToCreateDecoder { - return completion(.failure(.decodeProfileError)) + throw TCCProfileImportError.decodeProfileError } catch let DecodingError.keyNotFound(codingKey, _) { - return completion(TCCProfileImportResult.failure(.invalidProfileFile(description: codingKey.stringValue))) + throw TCCProfileImportError.invalidProfileFile(description: codingKey.stringValue) } catch let DecodingError.typeMismatch(type, context) { let errorDescription = "Type \(type) mismatch: \(context.debugDescription) codingPath: \(context.codingPath)" - return completion(.failure(.invalidProfileFile(description: errorDescription))) + throw TCCProfileImportError.invalidProfileFile(description: errorDescription) } catch let error as NSError { let errorDescription = error.userInfo["NSDebugDescription"] as? String - return completion(.failure(.invalidProfileFile(description: errorDescription ?? error.localizedDescription))) + throw TCCProfileImportError.invalidProfileFile(description: errorDescription ?? error.localizedDescription) } } - /// Mapping & Decoding tcc profile + /// Mapping & Decoding tcc profile from a file URL /// - /// - Parameter fileUrl: path with a file to load, completion: TCCProfileImportCompletion - success with TCCProfile or failure with TCCProfileImport Error - func decodeTCCProfile(fileUrl: URL, _ completion: @escaping TCCProfileImportCompletion) { + /// - Parameter fileUrl: path with a file to load + /// - Returns: The decoded TCCProfile + func decodeTCCProfile(fileUrl: URL) throws -> TCCProfile { let data: Data do { data = try Data(contentsOf: fileUrl) - return decodeTCCProfile(data: data, completion) } catch { - return completion(.failure(.unableToOpenFile)) + throw TCCProfileImportError.unableToOpenFile } - + return try decodeTCCProfile(data: data) } } diff --git a/Source/View Controllers/OpenViewController.swift b/Source/View Controllers/OpenViewController.swift index d8bb09b..d9b0026 100644 --- a/Source/View Controllers/OpenViewController.swift +++ b/Source/View Controllers/OpenViewController.swift @@ -43,16 +43,16 @@ class OpenViewController: NSViewController, NSTableViewDataSource, NSTableViewDe // Reload executables current = Model.shared.current if let value = current { - choices = Model.shared.getAppleEventChoices(executable: value) + Task { + choices = await Model.shared.getAppleEventChoices(executable: value) + } } } func tableView(_ tableView: NSTableView, selectionIndexesForProposedSelection proposedSelectionIndexes: IndexSet) -> IndexSet { - DispatchQueue.main.async { - guard let index = proposedSelectionIndexes.first else { return } - self.completionBlock?([.success(self.choices[index])]) - self.dismiss(self) - } + guard let index = proposedSelectionIndexes.first else { return proposedSelectionIndexes } + self.completionBlock?([.success(self.choices[index])]) + self.dismiss(self) return proposedSelectionIndexes } @@ -60,17 +60,24 @@ class OpenViewController: NSViewController, NSTableViewDataSource, NSTableViewDe let block = completionBlock let panel = NSOpenPanel() panel.allowsMultipleSelection = true - panel.allowedFileTypes = [ kUTTypeBundle, kUTTypeUnixExecutable ] as [String] + panel.allowedFileTypes = [kUTTypeBundle, kUTTypeUnixExecutable] as [String] panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) panel.begin { response in if response == .OK { - var selections: [LoadExecutableResult] = [] - panel.urls.forEach { - Model.shared.loadExecutable(url: $0) { result in - selections.append(result) + Task { + var selections: [LoadExecutableResult] = [] + for url in panel.urls { + do { + let executable = try await Model.shared.loadExecutable(url: url) + selections.append(.success(executable)) + } catch { + if let loadError = error as? LoadExecutableError { + selections.append(.failure(loadError)) + } + } } + block?(selections) } - block?(selections) } } } diff --git a/Source/View Controllers/SaveViewController.swift b/Source/View Controllers/SaveViewController.swift index c2d6081..dc45cab 100644 --- a/Source/View Controllers/SaveViewController.swift +++ b/Source/View Controllers/SaveViewController.swift @@ -64,12 +64,14 @@ class SaveViewController: NSViewController { var defaultsController = NSUserDefaultsController.shared func updateIsReadyToSave() { - guard isReadyToSave != ( - !organizationLabel.stringValue.isEmpty - && (payloadName != nil) - && !payloadName.isEmpty - && (payloadIdentifier != nil) - && !payloadIdentifier.isEmpty ) else { return } + guard + isReadyToSave + != (!organizationLabel.stringValue.isEmpty + && (payloadName != nil) + && !payloadName.isEmpty + && (payloadIdentifier != nil) + && !payloadIdentifier.isEmpty) + else { return } isReadyToSave = !isReadyToSave } @@ -83,10 +85,7 @@ class SaveViewController: NSViewController { panel.begin { response in if response == .OK { - // Let the save panel fully close itself before doing any work that may require keychain access. - DispatchQueue.main.async { - self.saveTo(url: panel.url!) - } + self.saveTo(url: panel.url!) } } } @@ -94,12 +93,14 @@ class SaveViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() payloadIdentifier = UUID().uuidString - do { - var identities = try SecurityWrapper.loadSigningIdentities() - identities.insert(SigningIdentity(name: "Not signed", reference: nil), at: 0) - identitiesPopUpAC.add(contentsOf: identities) - } catch { - logger.error("Error loading identities: \(error)") + Task { + do { + var identities = try await SecurityWrapper.loadSigningIdentities() + identities.insert(SigningIdentity(name: "Not signed", reference: nil), at: 0) + identitiesPopUpAC.add(contentsOf: identities) + } catch { + logger.error("Error loading identities: \(error)") + } } loadImportedTCCProfileInfo() @@ -118,10 +119,11 @@ class SaveViewController: NSViewController { defaultsController.removeObserver(self, forKeyPath: "values.organization", context: &SaveViewController.saveProfileKVOContext) } - // swiftlint:disable:next block_based_kvo - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + nonisolated override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { if context == &SaveViewController.saveProfileKVOContext { - updateIsReadyToSave() + MainActor.assumeIsolated { + updateIsReadyToSave() + } } else { super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) } @@ -130,22 +132,25 @@ class SaveViewController: NSViewController { func saveTo(url: URL) { logger.info("Saving to \(url, privacy: .public)") let model = Model.shared - let profile = model.exportProfile(organization: organizationLabel.stringValue, - identifier: payloadIdentifier, - displayName: payloadName, - payloadDescription: payloadDescription ?? payloadName) - do { - var outputData = try profile.xmlData() - if let identity = identitiesPopUpAC.selectedObjects.first as? SigningIdentity, let ref = identity.reference { - logger.info("Signing profile with \(identity.displayName)") - outputData = try SecurityWrapper.sign(data: outputData, using: ref) + let profile = model.exportProfile( + organization: organizationLabel.stringValue, + identifier: payloadIdentifier, + displayName: payloadName, + payloadDescription: payloadDescription ?? payloadName) + Task { + do { + var outputData = try profile.xmlData() + if let identity = identitiesPopUpAC.selectedObjects.first as? SigningIdentity, let ref = identity.reference { + logger.info("Signing profile with \(identity.displayName)") + outputData = try await SecurityWrapper.sign(data: outputData, using: ref) + } + try outputData.write(to: url) + logger.info("Saved successfully") + } catch { + logger.error("Error: \(error)") } - try outputData.write(to: url) - logger.info("Saved successfully") - } catch { - logger.error("Error: \(error)") + self.dismiss(nil) } - self.dismiss(nil) } func loadImportedTCCProfileInfo() { diff --git a/Source/View Controllers/TCCProfileViewController.swift b/Source/View Controllers/TCCProfileViewController.swift index 30e7a9e..02f39d0 100644 --- a/Source/View Controllers/TCCProfileViewController.swift +++ b/Source/View Controllers/TCCProfileViewController.swift @@ -146,23 +146,25 @@ class TCCProfileViewController: NSViewController { let logger = Logger.TCCProfileViewController - @IBAction func uploadAction(_ sender: NSButton) { - let identities: [SigningIdentity] - do { - identities = try SecurityWrapper.loadSigningIdentities() - } catch { - identities = [] - logger.error("Error loading identities: \(error.localizedDescription)") - } - - let uploadView = UploadInfoView(signingIdentities: identities) { - // Dismiss the sheet when the UploadInfoView decides it is done - if let controller = self.presentedViewControllers?.first { - self.dismiss(controller) - } - } - self.presentAsSheet(NSHostingController(rootView: uploadView)) - } + @IBAction func uploadAction(_ sender: NSButton) { + Task { + let identities: [SigningIdentity] + do { + identities = try await SecurityWrapper.loadSigningIdentities() + } catch { + identities = [] + logger.error("Error loading identities: \(error.localizedDescription)") + } + + let uploadView = UploadInfoView(signingIdentities: identities) { + // Dismiss the sheet when the UploadInfoView decides it is done + if let controller = self.presentedViewControllers?.first { + self.dismiss(controller) + } + } + self.presentAsSheet(NSHostingController(rootView: uploadView)) + } + } fileprivate func showAlert(_ error: LocalizedError, for window: NSWindow) { let alertWindow: NSAlert = NSAlert() @@ -181,15 +183,15 @@ class TCCProfileViewController: NSViewController { let tccProfileImporter = TCCProfileImporter() let tccConfigPanel = TCCProfileConfigurationPanel() - tccConfigPanel.loadTCCProfileFromFile(importer: tccProfileImporter, window: window) { [weak self] tccProfileResult in - guard let weakSelf = self else { return } - switch tccProfileResult { - case .success(let tccProfile): - weakSelf.model.importProfile(tccProfile: tccProfile) - case .failure(let tccProfileImportError): - if !tccProfileImportError.isCancelled { - weakSelf.showAlert(tccProfileImportError, for: window) + Task { + do { + if let tccProfile = try await tccConfigPanel.loadTCCProfileFromFile(importer: tccProfileImporter, window: window) { + await model.importProfile(tccProfile: tccProfile) } + } catch let error as TCCProfileImportError { + showAlert(error, for: window) + } catch { + showAlert(TCCProfileImportError.invalidProfileFile(description: error.localizedDescription), for: window) } } } @@ -197,26 +199,28 @@ class TCCProfileViewController: NSViewController { func promptForExecutables(_ block: @escaping (Executable) -> Void) { let panel = NSOpenPanel() panel.allowsMultipleSelection = true - panel.allowedFileTypes = [ kUTTypeBundle, kUTTypeUnixExecutable ] as [String] + panel.allowedFileTypes = [kUTTypeBundle, kUTTypeUnixExecutable] as [String] panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) guard let window = self.view.window else { return } panel.begin { response in if response == .OK { - panel.urls.forEach { - self.model.loadExecutable(url: $0) { [weak self] result in - switch result { - case .success(let executable): - guard self?.shouldExecutableBeAdded(executable) ?? false else { + Task { + for url in panel.urls { + do { + let executable = try await self.model.loadExecutable(url: url) + guard self.shouldExecutableBeAdded(executable) else { let error = LoadExecutableError.executableAlreadyExists - self?.showAlert(error, for: window) - return + self.showAlert(error, for: window) + continue } block(executable) - case .failure(let error): - self?.showAlert(error, for: window) - self?.logger.error("\(error)") + } catch { + if let loadError = error as? LoadExecutableError { + self.showAlert(loadError, for: window) + } + self.logger.error("\(error)") } } } @@ -225,48 +229,56 @@ class TCCProfileViewController: NSViewController { } let pasteboardOptions: [NSPasteboard.ReadingOptionKey: Any] = [ - .urlReadingContentsConformToTypes: [ kUTTypeBundle, kUTTypeUnixExecutable ] + .urlReadingContentsConformToTypes: [kUTTypeBundle, kUTTypeUnixExecutable] ] override func viewDidLoad() { super.viewDidLoad() // Setup policy pop up - setupAllowDeny(policies: [addressBookPopUpAC, - photosPopUpAC, - remindersPopUpAC, - calendarPopUpAC, - accessibilityPopUpAC, - postEventsPopUpAC, - adminFilesPopUpAC, - allFilesPopUpAC, - fileProviderPresencePopUpAC, - mediaLibraryPopUpAC, - speechRecognitionPopUpAC, - dekstopFolderPopUpAC, - documentsFolderPopUpAC, - downloadsFolderPopUpAC, - networkVolumesPopUpAC, - removableVolumesPopUpAC]) - - setupStandardUserAllowAndDeny(policies: [screenCapturePopUpAC, - listenEventPopUpAC]) - - setupDenyOnly(policies: [cameraPopUpAC, - microphonePopUpAC]) + setupAllowDeny(policies: [ + addressBookPopUpAC, + photosPopUpAC, + remindersPopUpAC, + calendarPopUpAC, + accessibilityPopUpAC, + postEventsPopUpAC, + adminFilesPopUpAC, + allFilesPopUpAC, + fileProviderPresencePopUpAC, + mediaLibraryPopUpAC, + speechRecognitionPopUpAC, + dekstopFolderPopUpAC, + documentsFolderPopUpAC, + downloadsFolderPopUpAC, + networkVolumesPopUpAC, + removableVolumesPopUpAC + ]) + + setupStandardUserAllowAndDeny(policies: [ + screenCapturePopUpAC, + listenEventPopUpAC + ]) + + setupDenyOnly(policies: [ + cameraPopUpAC, + microphonePopUpAC + ]) setupDescriptions() - setupStackViewsWithBackground(stackViews: [adminFilesStackView, - cameraStackView, - desktopFolderStackView, - downloadsFolderStackView, - allFilesStackView, - mediaLibraryStackView, - networkVolumesStackView, - postEventsStackView, - removableVolumesStackView, - speechRecognitionStackView]) + setupStackViewsWithBackground(stackViews: [ + adminFilesStackView, + cameraStackView, + desktopFolderStackView, + downloadsFolderStackView, + allFilesStackView, + mediaLibraryStackView, + networkVolumesStackView, + postEventsStackView, + removableVolumesStackView, + speechRecognitionStackView + ]) // Setup table views executablesTable.registerForDraggedTypes([.fileURL]) @@ -281,9 +293,11 @@ class TCCProfileViewController: NSViewController { private func setupStandardUserAllowAndDeny(policies: [NSArrayController]) { for policy in policies { - policy.add(contentsOf: ["-", - TCCProfileDisplayValue.allowStandardUsersToApprove.rawValue, - TCCProfileDisplayValue.deny.rawValue]) + policy.add(contentsOf: [ + "-", + TCCProfileDisplayValue.allowStandardUsersToApprove.rawValue, + TCCProfileDisplayValue.deny.rawValue + ]) } } @@ -370,7 +384,8 @@ class TCCProfileViewController: NSViewController { guard let source = self.executablesAC.selectedObjects.first as? Executable else { return } let rule = AppleEventRule(source: source, destination: executable, value: true) guard self.appleEventsAC.canInsert, - self.shouldAppleEventRuleBeAdded(rule) else { return } + self.shouldAppleEventRuleBeAdded(rule) + else { return } self.appleEventsAC.insert(rule, atArrangedObjectIndex: 0) } @@ -403,31 +418,32 @@ extension TCCProfileViewController: NSTableViewDataSource { guard let window = self.view.window else { return false } - var addedAny = false - urls?.forEach { (url) in - model.loadExecutable(url: url) { [weak self] result in - switch result { - case .success(let newExecutable): - if tableView == self?.executablesTable { - guard self?.executablesAC.canInsert ?? false else { - return + guard let urls = urls, !urls.isEmpty else { return false } + + Task { + for url in urls { + do { + let newExecutable = try await model.loadExecutable(url: url) + if tableView == self.executablesTable { + guard self.executablesAC.canInsert else { + continue } - if self?.shouldExecutableBeAdded(newExecutable) ?? false { - self?.executablesAC.insert(newExecutable, atArrangedObjectIndex: row) - addedAny = true + if self.shouldExecutableBeAdded(newExecutable) { + self.executablesAC.insert(newExecutable, atArrangedObjectIndex: row) } } else { - self?.insertIntoAppleEvents(newExecutable) - addedAny = true + self.insertIntoAppleEvents(newExecutable) + } + } catch { + if let loadError = error as? LoadExecutableError { + self.showAlert(loadError, for: window) } - case .failure(let error): - self?.showAlert(error, for: window) - self?.logger.error("\(error)") + self.logger.error("\(error)") } } } - return addedAny + return true } } diff --git a/Source/Views/Alert.swift b/Source/Views/Alert.swift index 89f50f0..f833459 100644 --- a/Source/Views/Alert.swift +++ b/Source/Views/Alert.swift @@ -27,21 +27,19 @@ import Cocoa -class Alert: NSObject { +class Alert { func display(header: String, message: String) { - DispatchQueue.main.async { - let dialog: NSAlert = NSAlert() - dialog.messageText = header - dialog.informativeText = message - dialog.alertStyle = NSAlert.Style.warning - dialog.addButton(withTitle: "OK") - dialog.runModal() - } + let dialog: NSAlert = NSAlert() + dialog.messageText = header + dialog.informativeText = message + dialog.alertStyle = NSAlert.Style.warning + dialog.addButton(withTitle: "OK") + dialog.runModal() } /// Displays a message with a cancel button and returns true if OK was pressed /// Assumes this method is called from the main queue. - /// + /// /// - Parameters: /// - header: The header message /// - message: The message body diff --git a/docs/plans/approachable-concurrency.md b/docs/plans/approachable-concurrency.md index 127629f..7ea242f 100644 --- a/docs/plans/approachable-concurrency.md +++ b/docs/plans/approachable-concurrency.md @@ -17,13 +17,13 @@ PPPC Utility is on Swift 5.0 with zero concurrency checking. The goal is to adop | PR | Stage | Status | Description | |----|-------|--------|-------------| | 1 | Stage 1 | ✅ Done | Enable Approachable Concurrency build settings | -| 2 | Stage 2 | Pending | Remove `NetworkAuthManager` actor → class | -| 3a | Stage 3a | Pending | Remove 3 `DispatchQueue.main.async` wrappers | -| 3b | Stage 3b | Pending | Convert `UploadManager` to async throws | -| 3c | Stage 3c | Pending | Convert `Model.loadExecutable` to direct return | -| 3d | Stage 3d | Pending | Convert `TCCProfileImporter` to direct return | -| 4 | Stage 4 | Pending | Add `@concurrent` for background I/O | -| 5 | Stage 5 | Pending | Enable Swift 6 language mode (warnings → errors) | +| 2 | Stage 2 | ✅ Done | Remove `NetworkAuthManager` actor → class | +| 3a | Stage 3a | ✅ Done | Remove 3 `DispatchQueue.main.async` wrappers | +| 3b | Stage 3b | ✅ Done | Convert `UploadManager` to async throws | +| 3c | Stage 3c | ✅ Done | Convert `Model.loadExecutable` to direct return | +| 3d | Stage 3d | ✅ Done | Convert `TCCProfileImporter` to direct return | +| 4 | Stage 4 | ✅ Done | Add `@concurrent` for background I/O | +| 5 | Stage 5 | ✅ Done | Enable Swift 6 language mode (warnings → errors) | PRs 3a–3d can be one PR or individual PRs — each is independently functional. PR 4 depends on PR 3c. PR 5 depends on all prior stages. diff --git a/docs/plans/xctest-to-swift-testing-migration.md b/docs/plans/xctest-to-swift-testing-migration.md new file mode 100644 index 0000000..7ab4919 --- /dev/null +++ b/docs/plans/xctest-to-swift-testing-migration.md @@ -0,0 +1,84 @@ +# XCTest → Swift Testing Migration Plan + +## Problem +Convert 52 test methods across 8 test files (plus 2 helpers) from XCTest to Swift Testing. The migration should be incremental and phased, starting with one file to establish patterns and build context. + +## Approach +- Migrate one file at a time, review after each phase +- Order files from simplest → most complex +- Keep XCTest and Swift Testing coexisting (the frameworks run side-by-side) +- Update CLAUDE.md with learned patterns/context after the first phase +- Helpers (ModelBuilder, TCCProfileBuilder) don't need conversion — they're plain Swift + +## Key Conversion Patterns + +| XCTest | Swift Testing | +|--------|---------------| +| `class FooTests: XCTestCase` | `@Suite` (line above) `struct FooTests` | +| `import XCTest` | `import Testing` | +| `func testFoo()` | `@Test` (line above) `func foo()` | +| `XCTAssertEqual(a, b)` | `#expect(a == b)` | +| `XCTAssertTrue(x)` | `#expect(x)` | +| `XCTAssertFalse(x)` | `#expect(!x)` | +| `XCTAssertNil(x)` | `#expect(x == nil)` | +| `XCTAssertNotNil(x)` | `try #require(x)` (unwraps) | +| `XCTUnwrap(x)` | `try #require(x)` | +| `XCTFail("msg")` | `Issue.record("msg")` | +| `setUp()` | `init()` | + +## Notes +- `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` is set — no explicit `@MainActor` needed +- `@testable import PPPC_Utility` stays the same +- Helpers (ModelBuilder, TCCProfileBuilder) are plain Swift — no changes needed +- `test` prefix on method names is dropped — `@Test` already marks them +- `@Test` and `@Suite` annotations go on the line above the declaration, not inline +- NetworkAuthManagerTests has async tests — Swift Testing supports `async throws` natively +- TCCProfileImporterTests uses callback-based patterns — may need `confirmation { }` macro + +## Progress + +- [x] **Phase 1: SemanticVersionTests** (3 tests) +- [x] **Phase 2: TokenTests** (5 tests) +- [x] **Phase 3: JamfProAPIClientTests + PPPCServicesManagerTests** (5 tests) +- [x] **Phase 4: NetworkAuthManagerTests** (7 tests) +- [x] **Phase 5: TCCProfileTests** (6 tests) +- [x] **Phase 6: TCCProfileImporterTests** (5 tests) +- [x] **Phase 7: ModelTests** (21 tests) +- [ ] **Phase 8: Fix stale storyboard outlet** — `addressBookStackView` connection in Main.storyboard references a property removed in Nov 2020 (renamed to `adminFilesStackView`). Pre-existing, not caused by migration. + +## Phases + +### Phase 1: SemanticVersionTests (3 tests) ✅ +- Simplest file: 3 test methods, no setUp/tearDown, no async, no mocks +- Only uses `XCTAssertTrue` and `XCTAssertFalse` +- Good candidate for parameterized tests (`@Test(arguments:)`) since each test runs multiple comparison assertions +- Establishes the basic conversion pattern for review + +### Phase 2: TokenTests (5 tests) ✅ +- Simple-medium: date checks and JSON decoding +- Uses `XCTAssertFalse`, `XCTAssertTrue`, `XCTAssertEqual`, `XCTAssertNotNil`, `XCTUnwrap` +- Introduces `try #require()` pattern (replacing XCTUnwrap) + +### Phase 3: JamfProAPIClientTests (1 test) + PPPCServicesManagerTests (4 tests) ✅ +- Both simple, batch together since JamfProAPIClientTests is only 1 test +- PPPCServicesManagerTests uses `XCTUnwrap` → `try #require()` + +### Phase 4: NetworkAuthManagerTests (7 tests) ✅ +- Async/await tests — Swift Testing handles these natively +- Has a MockNetworking class (stays as-is, it's not XCTest-specific) +- Error handling patterns with `do/catch` + `XCTFail` → `do/catch` + `Issue.record` + +### Phase 5: TCCProfileTests (6 tests) ✅ +- Serialization round-trip tests, uses TCCProfileBuilder helper +- Bundle resource loading (test bundle access pattern may need attention) + +### Phase 6: TCCProfileImporterTests (5 tests) ✅ +- Most complex conversion: callback-based async patterns +- May need Swift Testing's `confirmation { }` macro for callback verification +- Bundle resource loading for .mobileconfig test files + +### Phase 7: ModelTests (21 tests) ✅ +- Largest file (515 lines), saved for last +- Uses `setUp()` → convert to `init()` +- Heavy use of TCCProfileBuilder +- Comprehensive assertion coverage across all XCTAssert variants diff --git a/git-hooks/pre-commit.swift-format b/git-hooks/pre-commit.swift-format new file mode 100755 index 0000000..ea12b1d --- /dev/null +++ b/git-hooks/pre-commit.swift-format @@ -0,0 +1,24 @@ +#!/bin/bash +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +STAGED_SWIFT_FILES=$(git diff --cached --name-only -z --diff-filter=ACM | tr '\0' '\n' | grep '\.swift$') + +if [ -z "$STAGED_SWIFT_FILES" ]; then + exit 0 +fi + +echo -e "${GREEN}Running swift-format...${NC}" + +echo "$STAGED_SWIFT_FILES" | tr '\n' '\0' | xargs -0 xcrun swift-format --in-place -- +FAILED=$? + +if [ $FAILED -ne 0 ]; then + echo -e "${RED}Commit rejected: Swift formatting failed${NC}" + exit 1 +fi + +echo "$STAGED_SWIFT_FILES" | tr '\n' '\0' | xargs -0 git add -- +echo -e "${GREEN}✓ Swift files were formatted${NC}" +exit 0