Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions Sources/DeveloperPortal/AppIDs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)")
Expand Down
100 changes: 73 additions & 27 deletions Sources/DeveloperPortal/DeveloperPortalAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [:])")
Expand Down Expand Up @@ -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<T: Decodable>(_ 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)
}
}
}
15 changes: 14 additions & 1 deletion Sources/Models/Feature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Feature> = [
.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<Feature> = [
Expand Down
145 changes: 145 additions & 0 deletions Tests/SideSignTests/CapabilityTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}