Skip to content
Draft
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
32 changes: 32 additions & 0 deletions Documentation/xtool.docc/Signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Sign an existing app

Create a development-signed copy of an iOS or tvOS device app without rebuilding it.

## Overview

`xtool sign` accepts an existing `.app` directory or `.ipa` archive. It stages a copy, provisions and signs it using your saved `xtool auth` account, and writes a new `.app` or `.ipa`. The input remains unchanged. No Apple SDK is required for this command.

```bash
xtool sign Example.app --output Example-signed.ipa
xtool sign Example.ipa --output Example-signed.app --platform tvos
```

The output path must not exist, its parent directory must exist, and it must be outside the input app. IPA inputs must contain exactly one app directly inside `Payload`.

The signing platform is detected from the app's `CFBundleSupportedPlatforms`, or `UIDeviceFamily` when platform metadata is absent. Use `--platform ios` or `--platform tvos` if neither is present. An explicit platform must agree with the app's metadata. Simulator and macOS apps are not supported.

## Keep an existing bundle identifier

By default, signing adds xtool's account-specific prefix to the app and extension bundle identifiers. To keep their exact existing identifiers, use:

```bash
xtool sign Example.ipa --output Example-renewed.ipa --preserve-bundle-id
```

This also preserves identifiers that already start with `XTL-`. The identifiers must be available to the selected Apple Developer team. Keeping a bundle identifier does not transfer ownership of an app to another team. App-group identifiers still follow xtool's existing rewriting rules; this option does not promise continuity of shared containers or keychain data.

## Devices and renewal

The development profile includes compatible devices already registered with the account: Apple TVs for tvOS, and iPhones, iPads and iPods for iOS. Register the target device first using Xcode or the Apple Developer website, as supported by your account. Use `xtool ds devices list` to check registered devices. This command does not connect to, register or install on a device.

Signing uses xtool's existing certificate management and may prompt before revoking a certificate. Revocation can affect other installed development apps. Profile expiration and account limits remain Apple's limits, including short-lived profiles for free accounts. Run the command again with a fresh output path when renewing, then install the result using a deployment tool that supports your device and OS version.
1 change: 1 addition & 0 deletions Documentation/xtool.docc/xtool.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ xtool is a cross-platform (Linux/WSL/macOS) tool that replicates Xcode functiona

- <doc:Control>
- <doc:Appex>
- <doc:Signing>
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public struct DeveloperServicesAssignAppGroupsOperation: DeveloperServicesOperat
let groupID = ProvisioningIdentifiers.groupID(fromSanitized: sanitized, context: context)
let name = ProvisioningIdentifiers.groupName(fromSanitized: sanitized)
let request = DeveloperServicesAddAppGroupRequest(
platform: .iOS,
platform: context.platform.developerServicesPlatform,
teamID: xcodeAuthData.teamID,
name: name,
groupID: groupID
Expand All @@ -55,7 +55,7 @@ public struct DeveloperServicesAssignAppGroupsOperation: DeveloperServicesOperat

public func perform() async throws -> [DeveloperServicesAppGroup.GroupID] {
let existing = try await client.send(DeveloperServicesListAppGroupsRequest(
platform: .iOS, teamID: xcodeAuthData.teamID
platform: context.platform.developerServicesPlatform, teamID: xcodeAuthData.teamID
))
let sanitized = existing.map { (ProvisioningIdentifiers.sanitize(groupID: $0.groupID), $0) }
let dict = Dictionary(sanitized, uniquingKeysWith: { $1 })
Expand All @@ -65,7 +65,7 @@ public struct DeveloperServicesAssignAppGroupsOperation: DeveloperServicesOperat

_ = try await client.send(
DeveloperServicesAssignAppGroupRequest(
platform: .iOS,
platform: context.platform.developerServicesPlatform,
teamID: xcodeAuthData.teamID,
appIDID: appID.id,
groupIDs: groups.map(\.id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public struct DeveloperServicesAddAppOperation: DeveloperServicesOperation {
entitlements: Entitlements,
isFreeTeam: Bool
) async throws -> Components.Schemas.BundleId {
let newBundleID = ProvisioningIdentifiers.identifier(fromSanitized: bundleID, context: self.context)
let newBundleID = ProvisioningIdentifiers.bundleID(from: bundleID, context: self.context)

let existing = try await context.developerAPIClient
.bundleIdsGetCollection(query: .init(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation
let bundleIDs = try await context.developerAPIClient
.bundleIdsGetCollection(query: .init(
filter_lbrack_identifier_rbrack_: [bundleID],
fields_lbrack_profiles_rbrack_: [.bundleId],
fields_lbrack_profiles_rbrack_: [.profileType],
include: [.profiles]
))
.ok.body.json
Expand All @@ -49,7 +49,14 @@ public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation
throw Errors.tooManyMatchingBundleIDs
}

let profiles = bundleID.relationships?.profiles?.data ?? []
let relatedIDs = Set((bundleID.relationships?.profiles?.data ?? []).map(\.id))
let profiles = (bundleIDs.included ?? []).compactMap { resource -> Components.Schemas.Profile? in
guard case .profiles(let profile) = resource,
relatedIDs.contains(profile.id),
profile.attributes?.profileType?.value1?.rawValue == context.platform.developmentProfileType.rawValue
else { return nil }
return profile
}
switch profiles.count {
case 0:
// we're good
Expand All @@ -72,9 +79,16 @@ public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation
.reduce(into: [], +=)
.filter { $0.attributes?.serialNumber.map(signingInfo.certificate.hasSerialNumber) == true }

let allDevices = try await context.developerAPIClient.devicesGetCollection()
.ok.body.json.data
.filter { $0.attributes?.deviceClass?.value1?.supportsIOSApps == true }
let allDevices = try await DeveloperAPIPages {
try await context.developerAPIClient.devicesGetCollection().ok.body.json
} next: {
$0.links.next
}
.map(\.data)
.reduce(into: [], +=)
.filter { device in
device.attributes?.deviceClass?.value1.map(context.platform.supports(deviceClass:)) == true
}

let response = try await context.developerAPIClient.profilesCreateInstance(
body: .json(
Expand All @@ -83,7 +97,7 @@ public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation
_type: .profiles,
attributes: .init(
name: "XTL profile \(bundleID.id)",
profileType: .init(.iosAppDevelopment)
profileType: .init(context.platform.developmentProfileType)
),
relationships: .init(
bundleId: .init(
Expand All @@ -110,12 +124,3 @@ public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation
}

}

extension Components.Schemas.Device.AttributesPayload.DeviceClassPayload.Value1Payload {
fileprivate var supportsIOSApps: Bool {
switch self {
case .ipad, .iphone, .ipod: true
default: false
}
}
}
4 changes: 4 additions & 0 deletions Sources/XKit/Integration/IntegratedInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,14 @@ public actor IntegratedInstaller {
public func signInPlace(
app: URL,
targetDevice: SigningContext.TargetDevice? = nil,
platform: SigningPlatform = .iOS,
preserveBundleID: Bool = false,
) async throws -> String {
let context = SigningContext(
auth: auth,
targetDevice: targetDevice,
platform: platform,
preserveBundleID: preserveBundleID,
)

let signer = AutoSigner(context: context) { certs in
Expand Down
13 changes: 12 additions & 1 deletion Sources/XKit/Signer/SigningContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,32 @@ public struct SigningContext: Sendable {
public struct TargetDevice: Sendable, Hashable {
public var udid: String
public var name: String

public init(udid: String, name: String) {
self.udid = udid
self.name = name
}
}

public var auth: DeveloperAPIAuthData
public var targetDevice: TargetDevice?
public var platform: SigningPlatform
public var preserveBundleID: Bool

public var developerAPIClient: DeveloperAPIClient {
DeveloperAPIClient(auth: auth)
}

public init(
auth: DeveloperAPIAuthData,
targetDevice: TargetDevice? = nil
targetDevice: TargetDevice? = nil,
platform: SigningPlatform = .iOS,
preserveBundleID: Bool = false
) {
self.auth = auth
self.targetDevice = targetDevice
self.platform = platform
self.preserveBundleID = preserveBundleID
}

}
Expand Down
29 changes: 29 additions & 0 deletions Sources/XKit/Signer/SigningPlatform.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import DeveloperAPI

/// The platform whose development provisioning profile will be requested.
public enum SigningPlatform: String, CaseIterable, Sendable {
case iOS = "ios"
case tvOS = "tvos"

var developmentProfileType:
Components.Schemas.ProfileCreateRequest.DataPayload.AttributesPayload.ProfileTypePayload.Value1Payload {
switch self {
case .iOS: .iosAppDevelopment
case .tvOS: .tvosAppDevelopment
}
}

var developerServicesPlatform: DeveloperServicesPlatform {
switch self {
case .iOS: .iOS
case .tvOS: .tvOS
}
}

func supports(deviceClass: Components.Schemas.Device.AttributesPayload.DeviceClassPayload.Value1Payload) -> Bool {
switch (self, deviceClass) {
case (.iOS, .iphone), (.iOS, .ipad), (.iOS, .ipod), (.tvOS, .appleTv): true
default: false
}
}
}
5 changes: 5 additions & 0 deletions Sources/XKit/Utilities/ProvisioningIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ extension ProvisioningIdentifiers {
return "\(Self.idPrefix)\(uuid).\(sanitized)"
}

static func bundleID(from original: String, context: SigningContext) -> String {
if context.preserveBundleID { return original }
return identifier(fromSanitized: original, context: context)
}

static func safeify(identifier: String) -> String {
identifier.replacingOccurrences(of: ".", with: " ")
}
Expand Down
32 changes: 32 additions & 0 deletions Sources/XToolSupport/SignCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation
import ArgumentParser
import XKit
import XUtils

extension SigningPlatform: ExpressibleByArgument {}

struct SignCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "sign",
abstract: "Sign an existing iOS or tvOS app without building or installing it"
)

@Argument(help: "The .app directory or .ipa archive to sign") var path: String
@Option(name: [.short, .long], help: "A new .app or .ipa output path (must not exist)") var output: String
@Option(help: "Signing platform: ios or tvos (default: detect from the app)") var platform: SigningPlatform?
@Flag(help: "Keep the existing bundle identifiers instead of adding the xtool prefix") var preserveBundleID = false

func run() async throws {
let output = URL(fileURLWithPath: output)
let package = try await SigningPackage(input: URL(fileURLWithPath: path), output: output, platform: platform)
// Validate and stage before loading credentials or making any provisioning requests.
let token = try AuthToken.saved()
let delegate = XToolInstallerDelegate()
let installer = IntegratedInstaller(auth: token.authData(), delegate: delegate)
let bundleID = try await installer.signInPlace(
app: package.app, platform: package.platform, preserveBundleID: preserveBundleID
)
try await package.export(to: output)
d.out.print("\nSigned \(bundleID): \(output.path)")
}
}
Loading