diff --git a/INTEGRATION.md b/INTEGRATION.md index 1341bad..c91efc5 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -209,7 +209,7 @@ let paths = DDIPaths.default( ) ``` -Use the same `DDIPaths` for preparation, JIT enablement, and cache reset. The cache contains the image, trust cache, build manifest, cryptex info, and root hash. Do not validate cached DDI versions or contents yourself; StikJIT reuses readable, nonempty files and lets the device's cryptex service decide whether they work. +Use the same `DDIPaths` for preparation, JIT enablement, and cache reset. Before iOS 26.4, StikJIT downloads and mounts the three-file personalized DDI. On iOS 26.4 and newer, it downloads and installs the five-file cryptex DDI, which additionally includes cryptex info and a root hash. Do not validate cached DDI versions or contents yourself; StikJIT selects and replaces the cache format required by the running OS. A successful DDI mount persists until the device reboots, so it normally needs to be mounted only once per boot. @@ -276,7 +276,7 @@ Provide a **Reset Developer Disk Image** action that calls: try StikJIT.resetCachedDDI(at: paths) ``` -This removes only the five cached DDI files. The next preparation downloads them again if mounting is necessary. +This removes only the five possible cached DDI files. The next preparation downloads the three-file personalized DDI or five-file cryptex DDI required by the running OS if mounting is necessary. ## Configure the JIT methods diff --git a/Sources/DDISession.swift b/Sources/DDISession.swift index fd88cf3..8b9777c 100644 --- a/Sources/DDISession.swift +++ b/Sources/DDISession.swift @@ -1,6 +1,18 @@ import Foundation @_implementationOnly import idevice +private func ddiMountProgressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) { + guard let context else { return } + let progressHandler = Unmanaged.fromOpaque(context).takeUnretainedValue() + let fraction = total > 0 ? Double(progress) / Double(total) : 0 + progressHandler.handler(fraction) +} + +private final class DDIProgressBox { + let handler: (Double) -> Void + init(_ handler: @escaping (Double) -> Void) { self.handler = handler } +} + final class DDISession { private struct Tunnel { @@ -24,6 +36,10 @@ final class DDISession { let tunnel = try makeTunnel() defer { tunnel.free() } + if DDIMountMethod.current == .personalized { + return try isLegacyDDIMounted(over: tunnel) + } + var installed: UnsafeMutablePointer? let cryptexError = cryptexd_installed_ddi(tunnel.adapter, tunnel.handshake, &installed) defer { cryptexd_free_installed_cryptex(installed) } @@ -64,7 +80,16 @@ final class DDISession { return deviceCount > 0 } - func mountDDI(from directoryPath: String, progress: @escaping (Double) -> Void) throws { + func mountDDI(paths: DDIPaths, progress: @escaping (Double) -> Void) throws { + switch DDIMountMethod.current { + case .cryptex: + try installCryptexDDI(from: URL(fileURLWithPath: paths.manifestPath).deletingLastPathComponent().path, progress: progress) + case .personalized: + try mountPersonalizedDDI(paths: paths, progress: progress) + } + } + + private func installCryptexDDI(from directoryPath: String, progress: @escaping (Double) -> Void) throws { progress(0) var assets: OpaquePointer? try IdeviceFFI.check("failed to load DDI cryptex assets") { @@ -84,6 +109,86 @@ final class DDISession { progress(1) } + private func mountPersonalizedDDI(paths: DDIPaths, progress: @escaping (Double) -> Void) throws { + let imageData = try mappedFileData(atPath: paths.imagePath, description: "developer disk image") + let trustcacheData = try mappedFileData(atPath: paths.trustcachePath, description: "developer disk image trust cache") + let manifestData = try mappedFileData(atPath: paths.manifestPath, description: "developer disk image manifest") + + let tunnel = try makeTunnel() + defer { tunnel.free() } + + let uniqueChipID = try fetchUniqueChipID(over: tunnel) + + var client: OpaquePointer? + try IdeviceFFI.check("failed to connect to image mounter") { + image_mounter_connect_rsd(tunnel.adapter, tunnel.handshake, &client) + } + guard let client else { throw StikJITError.debugProxyUnavailable } + defer { image_mounter_free(client) } + + let progressBox = DDIProgressBox(progress) + let context = Unmanaged.passUnretained(progressBox).toOpaque() + + try IdeviceFFI.check("failed to mount personalized developer disk image") { + imageData.withUnsafeBytes { imageBuffer in + trustcacheData.withUnsafeBytes { trustcacheBuffer in + manifestData.withUnsafeBytes { manifestBuffer in + image_mounter_mount_personalized_with_callback_rsd( + client, + tunnel.adapter, + tunnel.handshake, + imageBuffer.bindMemory(to: UInt8.self).baseAddress, + imageData.count, + trustcacheBuffer.bindMemory(to: UInt8.self).baseAddress, + trustcacheData.count, + manifestBuffer.bindMemory(to: UInt8.self).baseAddress, + manifestData.count, + nil, + uniqueChipID, + ddiMountProgressCallback, + context) + } + } + } + } + } + + private func mappedFileData(atPath path: String, description: String) throws -> Data { + guard FileManager.default.fileExists(atPath: path) else { + throw StikJITError.ddiFilesMissing("\(description) not found at \(path)") + } + let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) + guard !data.isEmpty else { + throw StikJITError.ddiFilesMissing("\(description) is empty at \(path)") + } + return data + } + + private func fetchUniqueChipID(over tunnel: Tunnel) throws -> UInt64 { + var client: OpaquePointer? + try IdeviceFFI.check("failed to connect to lockdownd") { + lockdownd_connect_rsd(tunnel.adapter, tunnel.handshake, &client) + } + guard let client else { throw StikJITError.debugProxyUnavailable } + defer { lockdownd_client_free(client) } + + var uniqueChipIDPlist: plist_t? + try IdeviceFFI.check("failed to query UniqueChipID") { + "UniqueChipID".withCString { lockdownd_get_value(client, $0, nil, &uniqueChipIDPlist) } + } + guard let uniqueChipIDPlist else { + throw StikJITError.device(code: -1, subCode: 0, message: "UniqueChipID was not returned by lockdownd") + } + defer { plist_free(uniqueChipIDPlist) } + + var value: UInt64 = 0 + plist_get_uint_val(uniqueChipIDPlist, &value) + guard value != 0 else { + throw StikJITError.device(code: -1, subCode: 0, message: "Failed to decode UniqueChipID") + } + return value + } + private func openPairingFile() throws -> OpaquePointer { guard !pairingFilePath.isEmpty, FileManager.default.fileExists(atPath: pairingFilePath) else { throw StikJITError.pairingFile("not found at \(pairingFilePath)") diff --git a/Sources/DeveloperDiskImageService.swift b/Sources/DeveloperDiskImageService.swift index 052b6c1..6d97504 100644 --- a/Sources/DeveloperDiskImageService.swift +++ b/Sources/DeveloperDiskImageService.swift @@ -33,7 +33,8 @@ public struct DDIPaths: Sendable { var allFilesUsable: Bool { let fileManager = FileManager.default - return allPaths.allSatisfy { path in + let requiredPaths = DDIMountMethod.current == .cryptex ? allPaths : personalizedPaths + let requiredFilesUsable = requiredPaths.allSatisfy { path in guard fileManager.isReadableFile(atPath: path), let attributes = try? fileManager.attributesOfItem(atPath: path), attributes[.type] as? FileAttributeType == .typeRegular, @@ -42,10 +43,22 @@ public struct DDIPaths: Sendable { } return size.int64Value > 0 } + if DDIMountMethod.current == .personalized { + return requiredFilesUsable && cryptexOnlyPaths.allSatisfy { !fileManager.fileExists(atPath: $0) } + } + return requiredFilesUsable } var allPaths: [String] { - [imagePath, trustcachePath, manifestPath, cryptexInfoPath, rootHashPath] + personalizedPaths + cryptexOnlyPaths + } + + var personalizedPaths: [String] { + [imagePath, trustcachePath, manifestPath] + } + + var cryptexOnlyPaths: [String] { + [cryptexInfoPath, rootHashPath] } func removeCachedFiles() throws { @@ -54,6 +67,25 @@ public struct DDIPaths: Sendable { try fileManager.removeItem(atPath: path) } } + + func removeCryptexOnlyFiles() throws { + let fileManager = FileManager.default + for path in Set(cryptexOnlyPaths) where fileManager.fileExists(atPath: path) { + try fileManager.removeItem(atPath: path) + } + } +} + +enum DDIMountMethod: String { + case cryptex + case personalized + + static var current: DDIMountMethod { + let version = ProcessInfo.processInfo.operatingSystemVersion + return version.majorVersion > 26 || (version.majorVersion == 26 && version.minorVersion >= 4) + ? .cryptex + : .personalized + } } struct DDIDownloadItem { @@ -63,16 +95,19 @@ struct DDIDownloadItem { } enum DDIDownloadCatalog { - private static let baseURL = URL(string: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Cryptex")! - static func items(for paths: DDIPaths) -> [DDIDownloadItem] { - [ + let directory = DDIMountMethod.current == .cryptex ? "Xcode_iOS_DDI_Cryptex" : "Xcode_iOS_DDI_Personalized" + let baseURL = URL(string: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/\(directory)")! + var items = [ DDIDownloadItem(name: "BuildManifest.plist", destinationPath: paths.manifestPath, url: baseURL.appendingPathComponent("BuildManifest.plist")), DDIDownloadItem(name: "Image.dmg", destinationPath: paths.imagePath, url: baseURL.appendingPathComponent("Image.dmg")), DDIDownloadItem(name: "Image.dmg.trustcache", destinationPath: paths.trustcachePath, url: baseURL.appendingPathComponent("Image.dmg.trustcache")), - DDIDownloadItem(name: "Image.dmg.cryptex_info", destinationPath: paths.cryptexInfoPath, url: baseURL.appendingPathComponent("Image.dmg.cryptex_info")), - DDIDownloadItem(name: "Image.dmg.root_hash", destinationPath: paths.rootHashPath, url: baseURL.appendingPathComponent("Image.dmg.root_hash")), ] + if DDIMountMethod.current == .cryptex { + items.append(DDIDownloadItem(name: "Image.dmg.cryptex_info", destinationPath: paths.cryptexInfoPath, url: baseURL.appendingPathComponent("Image.dmg.cryptex_info"))) + items.append(DDIDownloadItem(name: "Image.dmg.root_hash", destinationPath: paths.rootHashPath, url: baseURL.appendingPathComponent("Image.dmg.root_hash"))) + } + return items } } @@ -103,6 +138,9 @@ public actor DeveloperDiskImageService { try await downloadFile(from: item.url, to: URL(fileURLWithPath: item.destinationPath)) progress(Double(index + 1) / total, "\(item.name) ready") } + if DDIMountMethod.current == .personalized { + try paths.removeCryptexOnlyFiles() + } } private func downloadFile(from url: URL, to destinationURL: URL) async throws { diff --git a/Sources/StikJIT.swift b/Sources/StikJIT.swift index a9186b3..0e44372 100644 --- a/Sources/StikJIT.swift +++ b/Sources/StikJIT.swift @@ -147,7 +147,7 @@ public enum StikJIT { progress(.downloadingDDI(fraction: fraction, status: status)) } } - try session.mountDDI(from: URL(fileURLWithPath: paths.manifestPath).deletingLastPathComponent().path) { fraction in + try session.mountDDI(paths: paths) { fraction in progress(.mountingDDI(fraction: fraction)) } progress(.verifyingDDI) @@ -184,7 +184,6 @@ public enum StikJIT { paths: DDIPaths, progress: @escaping (Double) -> Void = { _ in }) throws { let session = DDISession(pairingFilePath: pairingFile.path, configuration: configuration) - try session.mountDDI(from: URL(fileURLWithPath: paths.manifestPath).deletingLastPathComponent().path, - progress: progress) + try session.mountDDI(paths: paths, progress: progress) } } diff --git a/Sources/SynchronousDDIDownloader.swift b/Sources/SynchronousDDIDownloader.swift index 126d431..a69e1ef 100644 --- a/Sources/SynchronousDDIDownloader.swift +++ b/Sources/SynchronousDDIDownloader.swift @@ -16,6 +16,9 @@ enum SynchronousDDIDownloader { try download(item, session: session) progress(Double(index + 1) / total, "\(item.name) ready") } + if DDIMountMethod.current == .personalized { + try paths.removeCryptexOnlyFiles() + } } private static func download(_ item: DDIDownloadItem, session: URLSession) throws { diff --git a/project.yml b/project.yml index d91b187..c2c242a 100644 --- a/project.yml +++ b/project.yml @@ -8,8 +8,8 @@ options: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "1.8.0" - CURRENT_PROJECT_VERSION: "9" + MARKETING_VERSION: "1.9.0" + CURRENT_PROJECT_VERSION: "10" CLANG_CXX_LIBRARY: "libc++" targets: