From 9d89dac1d391c934e5c69be1fadbb090d9a7f944 Mon Sep 17 00:00:00 2001 From: playportdev Date: Mon, 5 Oct 2026 21:50:05 +0200 Subject: [PATCH] Enable Increased Memory Limit for free teams Apple offers the Increased Memory Limit capability to free teams, but the legacy updateAppId.action request cannot enable it, and Feature.freeFeatures did not list it, so SideStore dropped it and the profile came back without com.apple.developer.kernel.increased-memory-limit (SideStore#1616). - Feature.capabilityID names the developer services v1 capability ID of a feature the legacy request cannot enable (INCREASED_MEMORY_LIMIT). - updateAppID leaves such features out of the legacy request and, after it, enables them with a PATCH to v1/bundleIds/ (bundleIdCapabilities), the request Xcode, AltSign (b2c8861) and GetMoreRam send. If Apple refuses it, the update still succeeds without the capability, as before. - freeFeatures lists increasedMemoryLimit. - sendServicesBodyRequest sends a v1 request with its own method and JSON body; the header set is shared with sendServicesRequest. Tests mock both requests: the legacy body carries no modern feature, the PATCH body names the capability, no PATCH without it, and a refused PATCH does not fail the update. --- Sources/DeveloperPortal/AppIDs.swift | 56 +++++++ .../DeveloperPortal/DeveloperPortalAPI.swift | 100 ++++++++---- Sources/Models/Feature.swift | 15 +- Tests/SideSignTests/CapabilityTests.swift | 145 ++++++++++++++++++ 4 files changed, 288 insertions(+), 28 deletions(-) create mode 100644 Tests/SideSignTests/CapabilityTests.swift 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) + } +}