fix: restore personalized DDI mounting

This commit is contained in:
CelloSerenity 2026-09-27 14:58:13 -06:00
parent 5d732f94b8
commit 32287268fa
6 changed files with 160 additions and 15 deletions

View file

@ -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

View file

@ -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<DDIProgressBox>.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<InstalledCryptexC>?
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)")

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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: