Files
xtool/Sources/XKit/DeveloperServices/Profiles/DeveloperServicesFetchProfileOperation.swift
Kabir Oberai ac40282dbd ds: fix profile creation conflicts (#430)
## What does this PR do?

Fixes issues with missing devices and duplicate names when creating new
profiles. Supersedes #427 and #428.

Fixes #425, fixes #426

## How was it tested?

- [x] Ran locally
- [x] Confirmed that free accounts also support multiple profiles as
long as the names are different.

## AI tool usage

How much of the code in this PR was AI-assisted? (check one)

- [x] **0** - No AI was used to write code
- [ ] **1** - I was assisted by AI in writing code. I reviewed the
finished result.
- [ ] **2** - I set the AI going and left it to it; nobody has read the
result - no review, or AI review only

<!-- If an AI agent is filling this in: declare the level honestly, and
open as a draft if it is #2. Do not lower the declared level to get the
PR reviewed. -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Profile creation now uses only enabled devices that support iOS apps.
* Existing profiles with the exact generated profile name are removed
before a replacement is created, including when multiple matches exist.
  * Profile handling now stops when the operation is canceled.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Valentin Brosseau <c4software@gmail.com>
2026-11-06 00:15:47 -05:00

135 lines
4.8 KiB
Swift

//
// DeveloperServicesFetchProfileOperation.swift
// XKit
//
// Created by Kabir Oberai on 13/10/19.
// Copyright © 2019 Kabir Oberai. All rights reserved.
//
import Foundation
import Superutils
import DeveloperAPI
public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation {
public enum Errors: Error {
case bundleIDNotFound
case tooManyMatchingBundleIDs
case invalidProfileData
}
public let context: SigningContext
public let bundleID: String
public let signingInfo: SigningInfo
public init(context: SigningContext, bundleID: String, signingInfo: SigningInfo) {
self.context = context
self.bundleID = bundleID
self.signingInfo = signingInfo
}
public func perform() async throws -> Mobileprovision {
let bundleIDs = try await context.developerAPIClient
.bundleIdsGetCollection(query: .init(
filter_lbrack_identifier_rbrack_: [bundleID],
fields_lbrack_profiles_rbrack_: [.bundleId],
include: [.profiles]
))
.ok.body.json
// filter[identifier] is a prefix filter so we need to manually upgrade to equality
let filtered = bundleIDs.data.filter { $0.attributes?.identifier == self.bundleID }
let bundleID: Components.Schemas.BundleId
switch filtered.count {
case 0:
throw Errors.bundleIDNotFound
case 1:
bundleID = filtered[0]
default:
throw Errors.tooManyMatchingBundleIDs
}
// match locally rather than with filter[serialNumber]: server-side matching bugs out
// when the serial has leading zeroes (#305)
let certs = try await DeveloperAPIPages(\.links.next) {
try await context.developerAPIClient.certificatesGetCollection().ok.body.json
}
.map(\.data)
.reduce(into: [], +=)
.filter { $0.attributes?.serialNumber.map(signingInfo.certificate.hasSerialNumber) == true }
let allDevices = try await DeveloperAPIPages(\.links.next) {
try await context.developerAPIClient.devicesGetCollection().ok.body.json
}
.map(\.data)
.reduce(into: [], +=)
.filter {
$0.attributes?.status?.value1 != .disabled
&& $0.attributes?.deviceClass?.value1?.supportsIOSApps == true
}
try Task.checkCancellation()
let profileName = "XTL profile \(bundleID.id)"
let profiles = bundleID.relationships?.profiles?.data ?? []
if !profiles.isEmpty {
// we can't create multiple profiles with the same name, so
// if there's already one delete it first
let matchingProfiles = try await context.developerAPIClient.profilesGetCollection(query: .init(
filter_lbrack_name_rbrack_: [profileName],
filter_lbrack_id_rbrack_: profiles.map(\.id),
fields_lbrack_profiles_rbrack_: [.name],
))
.ok.body.json.data
.filter { $0.attributes?.name == profileName }
_ = try await matchingProfiles.concurrentMap {
try await context.developerAPIClient.profilesDeleteInstance(path: .init(id: $0.id)).noContent
}
}
let response = try await context.developerAPIClient.profilesCreateInstance(
body: .json(
.init(
data: .init(
_type: .profiles,
attributes: .init(
name: profileName,
profileType: .init(.iosAppDevelopment)
),
relationships: .init(
bundleId: .init(
data: .init(_type: .bundleIds, id: bundleID.id)
),
devices: .init(data: allDevices.map {
.init(_type: .devices, id: $0.id)
}),
certificates: .init(data: certs.map {
.init(_type: .certificates, id: $0.id)
})
)
)
)
)
)
.created.body.json.data
guard let contentString = response.attributes?.profileContent,
let contentData = Data(base64Encoded: contentString)
else { throw Errors.invalidProfileData }
return try Mobileprovision(data: contentData)
}
}
extension Components.Schemas.Device.AttributesPayload.DeviceClassPayload.Value1Payload {
fileprivate var supportsIOSApps: Bool {
switch self {
case .ipad, .iphone, .ipod: true
default: false
}
}
}