diff --git a/Sources/DeveloperPortal/AppIDs.swift b/Sources/DeveloperPortal/AppIDs.swift index efb5c44..2dfdeb9 100644 --- a/Sources/DeveloperPortal/AppIDs.swift +++ b/Sources/DeveloperPortal/AppIDs.swift @@ -73,7 +73,15 @@ public extension DeveloperPortal { "appIdId": appID.identifier ] + var capabilityIDs: [String] = [] for (feature, value) in appID.features { + if let capabilityID = feature.capabilityID { + // Not a legacy feature: enabled below with a v1 request. + if value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true" { + capabilityIDs.append(capabilityID) + } + continue + } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.lowercased() == "true" { parameters[feature.rawValue] = true @@ -93,9 +101,57 @@ public extension DeveloperPortal { debugLog("[SideSign] updateAppID succeeded") verboseLog("[SideSign] Updated: \(updatedAppID.name) (\(updatedAppID.bundleIdentifier))") + + if !capabilityIDs.isEmpty { + // A failure here leaves the App ID as the legacy request left it, + // so the app still installs, without these capabilities. + do { + try await enableCapabilities(capabilityIDs.sorted(), for: updatedAppID, team: team, session: session) + } catch { + debugLog("[SideSign] updateAppID: could not enable \(capabilityIDs.sorted()): \(error.localizedDescription); continuing without them") + } + } return updatedAppID } + /// Enables capabilities that only the developer services v1 API can enable + /// (Increased Memory Limit). This is the request Xcode sends, and the one + /// AltSign and GetMoreRam send; Apple accepts it for free teams too. + func enableCapabilities(_ capabilityIDs: [String], for appID: AppID, team: Team, session: Session) async throws { + debugLog("[SideSign] enableCapabilities starting...") + verboseLog("[SideSign] AppID: \(appID.bundleIdentifier), capabilities: \(capabilityIDs)") + + let capabilities: [[String: Any]] = capabilityIDs.map { capabilityID in + [ + "type": "bundleIdCapabilities", + "attributes": ["enabled": true, "settings": [Any]()], + "relationships": ["capability": ["data": ["id": capabilityID, "type": "capabilities"]]] + ] + } + let body: [String: Any] = [ + "data": [ + "id": appID.identifier, + "type": "bundleIds", + "attributes": [ + "name": appID.name, + "identifier": appID.bundleIdentifier, + "teamId": team.identifier, + "seedId": team.identifier, + "bundleType": "bundle", + "hasExclusiveManagedCapabilities": false + ], + "relationships": ["bundleIdCapabilities": ["data": capabilities]] + ] + ] + + var request = URLRequest(url: URL(string: "bundleIds/\(appID.identifier)", relativeTo: servicesBaseURL)!) + request.httpMethod = "PATCH" + request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.sortedKeys]) + + let _: EmptyResponse = try await sendServicesBodyRequest(request, session: session) + debugLog("[SideSign] enableCapabilities succeeded") + } + func deleteAppID(_ appID: AppID, for team: Team, session: Session) async throws -> Bool { debugLog("[SideSign] deleteAppID starting...") verboseLog("[SideSign] AppID: \(appID.name) (\(appID.bundleIdentifier)), Team: \(team.name)") diff --git a/Sources/DeveloperPortal/DeveloperPortalAPI.swift b/Sources/DeveloperPortal/DeveloperPortalAPI.swift index dfea7cc..e3302db 100644 --- a/Sources/DeveloperPortal/DeveloperPortalAPI.swift +++ b/Sources/DeveloperPortal/DeveloperPortalAPI.swift @@ -419,33 +419,7 @@ public final class DeveloperPortal: DeveloperPortalAPI, Sendable { request.httpBody = bodyData request.setValue(methodOverride, forHTTPHeaderField: "X-HTTP-Method-Override") - let h = customHeaders - var headers: [String: String] = [ - "Content-Type": "application/vnd.api+json", - "User-Agent": h.developerServices.userAgent, - "Accept": "application/vnd.api+json", - "Accept-Language": "en-us", - "X-Apple-App-Info": h.grandSlam.authApp, - "X-Xcode-Version": apiSession.xcodeVersion, - "X-Apple-I-Identity-Id": apiSession.dsid, - "X-Apple-GS-Token": apiSession.authToken - ] - - if includeAnisette { - let a = apiSession.anisetteData - headers["X-Apple-I-MD-M"] = a.machineID - headers["X-Apple-I-MD"] = a.oneTimePassword - headers["X-Apple-I-MD-LU"] = a.localUserID - headers["X-Apple-I-MD-RINFO"] = a.routingInfo - headers["X-Mme-Device-Id"] = a.deviceID - headers["X-MMe-Client-Info"] = a.clientInfo - headers["X-Apple-I-Client-Time"] = a.clientTime - headers["X-Apple-Locale"] = a.locale - headers["X-Apple-I-Locale"] = a.locale - headers["X-Apple-I-TimeZone"] = a.timeZone - } - - headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } + servicesHeaders(for: apiSession, includeAnisette: includeAnisette).forEach { request.setValue($1, forHTTPHeaderField: $0) } verboseLog("[SideSign] sendServicesRequest to: \(request.url?.absoluteString ?? "unknown URL")") verboseLog("[SideSign] sendServicesRequest parameters: \(additionalParameters ?? [:])") @@ -508,4 +482,76 @@ public final class DeveloperPortal: DeveloperPortalAPI, Sendable { throw ServerError.invalidResponseFormat(rawPayload: rawStr) } } + + func servicesHeaders(for apiSession: Session, includeAnisette: Bool) -> [String: String] { + let h = customHeaders + var headers: [String: String] = [ + "Content-Type": "application/vnd.api+json", + "User-Agent": h.developerServices.userAgent, + "Accept": "application/vnd.api+json", + "Accept-Language": "en-us", + "X-Apple-App-Info": h.grandSlam.authApp, + "X-Xcode-Version": apiSession.xcodeVersion, + "X-Apple-I-Identity-Id": apiSession.dsid, + "X-Apple-GS-Token": apiSession.authToken + ] + + if includeAnisette { + let a = apiSession.anisetteData + headers["X-Apple-I-MD-M"] = a.machineID + headers["X-Apple-I-MD"] = a.oneTimePassword + headers["X-Apple-I-MD-LU"] = a.localUserID + headers["X-Apple-I-MD-RINFO"] = a.routingInfo + headers["X-Mme-Device-Id"] = a.deviceID + headers["X-MMe-Client-Info"] = a.clientInfo + headers["X-Apple-I-Client-Time"] = a.clientTime + headers["X-Apple-Locale"] = a.locale + headers["X-Apple-I-Locale"] = a.locale + headers["X-Apple-I-TimeZone"] = a.timeZone + } + return headers + } + + // JSON:API Request Dispatcher: sends the request's own method and JSON body + // (a PATCH, say), where sendServicesRequest tunnels a query through POST. + func sendServicesBodyRequest(_ originalRequest: URLRequest, session apiSession: Session) async throws -> T { + var request = originalRequest + servicesHeaders(for: apiSession, includeAnisette: true).forEach { request.setValue($1, forHTTPHeaderField: $0) } + + verboseLog("[SideSign] sendServicesBodyRequest \(request.httpMethod ?? "GET") to: \(request.url?.absoluteString ?? "unknown URL")") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + debugLog("[SideSign] sendServicesBodyRequest network error: \(error)") + throw error + } + + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + if let payloadString = formatPayload(data) { + verboseLog("[SideSign] sendServicesBodyRequest response (HTTP \(statusCode)): \(payloadString)") + } + + if let status = try? JSONDecoder().decode(DeveloperPortalStatusResponse.self, from: data), + let firstError = status.errors?.first { + let message = firstError.detail ?? firstError.title ?? "Apple Developer Services Error" + debugLog("[SideSign] sendServicesBodyRequest error (HTTP \(statusCode)): \(message)") + throw ServerError.underlyingError(code: Int(firstError.status ?? "") ?? statusCode, message: message) + } + guard (200..<300).contains(statusCode) else { + throw ServerError.underlyingError(code: statusCode, message: "Apple Developer Services returned HTTP \(statusCode)") + } + + if data.isEmpty, let empty = EmptyResponse() as? T { + return empty + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + let rawStr = String(data: data, encoding: .utf8) ?? data.hexEncodedString() + debugLog("[SideSign] sendServicesBodyRequest failed to decode \(T.self): \(error). Raw payload: \(rawStr)") + throw ServerError.invalidResponseFormat(rawPayload: rawStr) + } + } } diff --git a/Sources/Models/Feature.swift b/Sources/Models/Feature.swift index 554fc75..716a933 100644 --- a/Sources/Models/Feature.swift +++ b/Sources/Models/Feature.swift @@ -75,9 +75,22 @@ public extension Feature { static let extendedVirtualAddressing: Feature = "extendedVirtualAddressing" static let increasedDebuggingMemoryLimit: Feature = "increasedDebuggingMemoryLimit" + /// The capability ID the developer services v1 `bundleIdCapabilities` + /// endpoint uses for a feature the legacy `updateAppId.action` request + /// cannot enable, or nil for a legacy feature. + var capabilityID: String? { + switch self { + case .increasedMemoryLimit: return "INCREASED_MEMORY_LIMIT" + default: return nil + } + } + static let freeFeatures: Set = [ .appGroups, - .interAppAudio + .interAppAudio, + // Apple offers this capability to free teams; it is enabled through + // the v1 bundleIdCapabilities request (see `capabilityID`). + .increasedMemoryLimit ] static let paidFeatures: Set = [ diff --git a/Tests/SideSignTests/CapabilityTests.swift b/Tests/SideSignTests/CapabilityTests.swift new file mode 100644 index 0000000..da9c994 --- /dev/null +++ b/Tests/SideSignTests/CapabilityTests.swift @@ -0,0 +1,145 @@ +// +// CapabilityTests.swift +// SideSignTests +// + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import SideSign + +/// Answers the legacy updateAppId.action request and the v1 bundleIds PATCH, +/// and records both requests. +final class CapabilityURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var requests: [(method: String, url: URL, body: Data)] = [] + nonisolated(unsafe) static var patchStatus = 200 + static let lock = NSLock() + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + var body = request.httpBody ?? Data() + if body.isEmpty, let stream = request.httpBodyStream { + stream.open() + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let n = stream.read(&buffer, maxLength: buffer.count) + if n <= 0 { break } + body.append(buffer, count: n) + } + stream.close() + } + let url = request.url! + let method = request.httpMethod ?? "GET" + Self.lock.withLock { Self.requests.append((method, url, body)) } + + let status: Int + let data: Data + if url.path.hasSuffix("updateAppId.action") { + status = 200 + data = try! PropertyListSerialization.data(fromPropertyList: [ + "resultCode": 0, + "appId": ["appIdId": "ABCDE12345", "name": "App", "identifier": "com.example.app", + "features": ["APG3427HIY": true]] + ], format: .xml, options: 0) + } else if Self.patchStatus == 200 { + status = 200 + data = Data(#"{"data":{"id":"ABCDE12345","type":"bundleIds"}}"#.utf8) + } else { + status = Self.patchStatus + data = Data(#"{"errors":[{"status":"409","code":"ENTITY_ERROR","title":"conflict","detail":"capability not allowed"}]}"#.utf8) + } + let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +@Suite("Increased Memory Limit capability", .serialized) +struct CapabilityTests { + let team = Team(identifier: "TEAM123456", name: "Free Team", type: .free) + let session = Session( + dsid: "1", authToken: "token", + anisetteData: AnisetteData(machineID: "m", oneTimePassword: "o", localUserID: "l", routingInfo: "0", + deviceID: "d", serialNumber: "0", clientInfo: "c", userAgent: "u", + clientTime: "2026-10-05T00:00:00Z", locale: "en_US", timeZone: "UTC"), + xcodeVersion: "11.2 (11B41)") + + func portal() -> DeveloperPortal { + CapabilityURLProtocol.requests = [] + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [CapabilityURLProtocol.self] + return DeveloperPortal(session: URLSession(configuration: configuration)) + } + + @Test + func freeTeamsAllowIncreasedMemoryLimit() { + #expect(Feature.freeFeatures.contains(.increasedMemoryLimit)) + #expect(Feature(entitlement: .increasedMemoryLimit) == .increasedMemoryLimit) + #expect(Feature.increasedMemoryLimit.capabilityID == "INCREASED_MEMORY_LIMIT") + #expect(Feature.appGroups.capabilityID == nil) + } + + @Test + func updateAppIDEnablesTheCapabilityWithAV1Patch() async throws { + CapabilityURLProtocol.patchStatus = 200 + let portal = portal() + let appID = AppID(identifier: "ABCDE12345", name: "App", bundleIdentifier: "com.example.app", + features: [.appGroups: "true", .increasedMemoryLimit: "true"]) + _ = try await portal.updateAppID(appID, team: team, session: session) + + let requests = CapabilityURLProtocol.requests + #expect(requests.count == 2) + + // The legacy request carries only legacy features. + let legacy = try #require(requests.first) + #expect(legacy.url.path.hasSuffix("/ios/updateAppId.action")) + let legacyBody = try #require(PropertyListSerialization.propertyList(from: legacy.body, format: nil) as? [String: Any]) + #expect(legacyBody["APG3427HIY"] as? Bool == true) + #expect(legacyBody["increasedMemoryLimit"] == nil) + + let patch = try #require(requests.last) + #expect(patch.method == "PATCH") + #expect(patch.url.absoluteString == "https://developerservices2.apple.com/services/v1/bundleIds/ABCDE12345") + let json = try #require(JSONSerialization.jsonObject(with: patch.body) as? [String: Any]) + let data = try #require(json["data"] as? [String: Any]) + #expect(data["id"] as? String == "ABCDE12345") + #expect(data["type"] as? String == "bundleIds") + let attributes = try #require(data["attributes"] as? [String: Any]) + #expect(attributes["identifier"] as? String == "com.example.app") + #expect(attributes["teamId"] as? String == "TEAM123456") + let relationships = try #require(data["relationships"] as? [String: Any]) + let capabilities = try #require((relationships["bundleIdCapabilities"] as? [String: Any])?["data"] as? [[String: Any]]) + #expect(capabilities.count == 1) + let capability = try #require(((capabilities.first?["relationships"] as? [String: Any])?["capability"] as? [String: Any])?["data"] as? [String: Any]) + #expect(capability["id"] as? String == "INCREASED_MEMORY_LIMIT") + #expect((capabilities.first?["attributes"] as? [String: Any])?["enabled"] as? Bool == true) + } + + @Test + func noPatchWithoutTheFeature() async throws { + let portal = portal() + let appID = AppID(identifier: "ABCDE12345", name: "App", bundleIdentifier: "com.example.app", + features: [.appGroups: "true", .increasedMemoryLimit: "false"]) + _ = try await portal.updateAppID(appID, team: team, session: session) + #expect(CapabilityURLProtocol.requests.count == 1) + } + + @Test + func aRefusedCapabilityDoesNotFailTheUpdate() async throws { + CapabilityURLProtocol.patchStatus = 409 + defer { CapabilityURLProtocol.patchStatus = 200 } + let portal = portal() + let appID = AppID(identifier: "ABCDE12345", name: "App", bundleIdentifier: "com.example.app", + features: [.appGroups: "true", .increasedMemoryLimit: "true"]) + let updated = try await portal.updateAppID(appID, team: team, session: session) + #expect(updated.identifier == "ABCDE12345") + #expect(CapabilityURLProtocol.requests.count == 2) + } +}