diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72c6b67..b2c39c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,11 +116,18 @@ jobs: release-build: name: Release packaging runs-on: macos-26 - timeout-minutes: 45 + timeout-minutes: 90 steps: - uses: actions/checkout@v7 - name: Select Xcode run: scripts/ci-select-xcode.sh + - name: Install the firmware helpers' build tools + run: brew install autoconf automake libtool pkg-config cmake + - name: Reuse the firmware helpers built from the same pinned sources + uses: actions/cache@v6 + with: + path: build-output/restore-helpers + key: restore-helpers-${{ hashFiles('scripts/build-restore-helpers.sh') }}-${{ env.XCODE_VERSION }} - name: Build, sign, and verify a release (not published) # An empty VERSION means the version in ToolkitVersion.swift. run: scripts/build-release.sh "" "$RUNNER_TEMP/release" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 686f94c..e4dbabe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,13 +17,20 @@ jobs: build: name: Build universal app runs-on: macos-26 - timeout-minutes: 60 + timeout-minutes: 90 steps: - uses: actions/checkout@v7 - name: Select Xcode run: scripts/ci-select-xcode.sh - name: Test before packaging run: swift test -Xswiftc -warnings-as-errors + - name: Install the firmware helpers' build tools + run: brew install autoconf automake libtool pkg-config cmake + - name: Reuse the firmware helpers built from the same pinned sources + uses: actions/cache@v6 + with: + path: build-output/restore-helpers + key: restore-helpers-${{ hashFiles('scripts/build-restore-helpers.sh') }}-${{ env.XCODE_VERSION }} - name: Build, sign, and verify the release # Fails unless the tag (without "v") matches ToolkitVersion.current and MARKETING_VERSION. run: scripts/build-release.sh "${GITHUB_REF_NAME#v}" release-assets @@ -56,6 +63,7 @@ jobs: zip="release-assets/iOS-Developer-Toolkit-Swift-$version-macOS-universal.zip" sbom="release-assets/iOS-Developer-Toolkit-Swift-$version.spdx.json" test -f "../$zip" && test -f "../$sbom" + test -f "iOS-Developer-Toolkit-Swift-$version-firmware-helpers-source.tar.gz" echo "zip=$zip" >> "$GITHUB_OUTPUT" echo "sbom=$sbom" >> "$GITHUB_OUTPUT" - name: Attest build provenance @@ -75,4 +83,4 @@ jobs: --repo "$GITHUB_REPOSITORY" \ --title "iOS Developer Toolkit $GITHUB_REF_NAME" \ --generate-notes \ - --notes "Universal (Apple silicon and Intel) macOS app, ad-hoc signed with the hardened runtime and not notarized. Verify SHA256SUMS.txt and the GitHub attestation before opening; see docs/release-verification.md." + --notes "Universal (Apple silicon and Intel) macOS app, ad-hoc signed with the hardened runtime and not notarized. Verify SHA256SUMS.txt and the GitHub attestation before opening; see docs/release-verification.md. The firmware helpers' complete source (idevicerestore and the libimobiledevice libraries, LGPL) is attached as firmware-helpers-source.tar.gz." diff --git a/.gitignore b/.gitignore index 94663ea..4343133 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ location-events.jsonl *.cer *.dmg *.ipa +*.ipsw *.mobileconfig *.trustcache BuildManifest.plist diff --git a/App/UITests/SmokeUITests.swift b/App/UITests/SmokeUITests.swift index 72524a0..a7b963b 100644 --- a/App/UITests/SmokeUITests.swift +++ b/App/UITests/SmokeUITests.swift @@ -41,7 +41,7 @@ final class SmokeUITests: XCTestCase { } func testEveryWorkspaceOpens() throws { - let workspaces = ["overview", "device", "developerImage", "readiness", "apps", "installApp", "location", "liveLogs", "actions", "backup", "evidence", "externalTools", "activity", "help", "safety"] + let workspaces = ["overview", "device", "developerImage", "firmware", "readiness", "apps", "installApp", "location", "liveLogs", "actions", "backup", "evidence", "externalTools", "activity", "help", "safety"] for workspace in workspaces { let item = app.descendants(matching: .any)["sidebar-\(workspace)"] XCTAssertTrue(item.waitForExistence(timeout: 5), "Missing sidebar item \(workspace)") diff --git a/App/iOSDeveloperToolkit/Model/AppModel.swift b/App/iOSDeveloperToolkit/Model/AppModel.swift index 7d831fd..7bb6556 100644 --- a/App/iOSDeveloperToolkit/Model/AppModel.swift +++ b/App/iOSDeveloperToolkit/Model/AppModel.swift @@ -106,6 +106,7 @@ final class AppModel { // Feature models let developerImage: DeveloperImageModel + let firmware: FirmwareModel let location: LocationModel let logs: LiveLogsModel let apps: AppsModel @@ -134,6 +135,7 @@ final class AppModel { : .init() discovery = DeviceDiscovery(configuration: configuration) developerImage = DeveloperImageModel() + firmware = FirmwareModel() location = LocationModel() logs = LiveLogsModel() apps = AppsModel() diff --git a/App/iOSDeveloperToolkit/Model/FirmwareModel.swift b/App/iOSDeveloperToolkit/Model/FirmwareModel.swift new file mode 100644 index 0000000..c341bbb --- /dev/null +++ b/App/iOSDeveloperToolkit/Model/FirmwareModel.swift @@ -0,0 +1,419 @@ +import AppKit +import DeviceKit +import Foundation +import Observation +import ToolkitCore +import ToolkitFeatures + +/// The device the Firmware page works with: a connected device in normal mode, or one in +/// recovery or DFU mode (which the rest of the app cannot see). +struct FirmwareDevice: Hashable { + enum State: Hashable { + case normal + case recovery(RecoveryDevice) + case demo + } + + var state: State + var name: String + var productType: String? + /// The build identity's device class (`d93ap`), used to pick the right one. + var deviceClass: String? + var target: DeviceTarget + + var installTarget: FirmwareInstall.Target { + switch state { + case .recovery(let device): return .ecid(device.ecid) + case .normal, .demo: return .udid(target.udid) + } + } + + var modeLabel: String { + switch state { + case .normal: return "Normal" + case .recovery(let device): return device.mode.label + case .demo: return "Normal (demo)" + } + } + + init(_ device: Device) { + state = device.kind == .demo ? .demo : .normal + name = device.name + productType = device.productType + deviceClass = device.hardwareModel?.lowercased() + target = device.target + } + + init(_ recovery: RecoveryDevice) { + state = .recovery(recovery) + name = "\(recovery.productType ?? "Device") in \(recovery.mode.label.lowercased())" + productType = recovery.productType + deviceClass = recovery.model?.lowercased() + target = DeviceTarget(kind: .physical, udid: recovery.ecid, name: name, osVersion: nil, usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: .usb) + } +} + +/// Firmware (IPSW) state: Apple's firmware for the device, signing status, downloads, the local +/// library, recovery and DFU mode, and installation through the bundled idevicerestore. +@Observable +@MainActor +final class FirmwareModel { + // Apple's firmware for the current model. + private(set) var releases: [FirmwareRelease] = [] + private(set) var releasesProductType: String? + private(set) var isLoadingCatalog = false + private(set) var catalogError: String? + /// Signing status by build (Apple's firmware) or by path (library files). + private(set) var signing: [String: FirmwareSigning.Status] = [:] + private(set) var checkingSigning: Set = [] + + // Downloads, by build. + private(set) var downloads: [String: (written: Int64, total: Int64)] = [:] + private var downloadTasks: [String: Task] = [:] + + // The library. + private(set) var library: [IPSWFile] = [] + private(set) var isScanning = false + private(set) var verification: [String: String] = [:] + private(set) var verifying: [String: Double] = [:] + + // Recovery and DFU. + private(set) var recoveryDevice: RecoveryDevice? + private(set) var helperProblem: String? + + // Installing. + var selectedIPSW: String? + var mode: FirmwareInstall.Mode = .update + private(set) var preflight: [FirmwarePreflight.Check] = [] + private(set) var isPreflighting = false + private(set) var isInstalling = false + private(set) var installStep: String? + private(set) var installFraction: Double = 0 + private(set) var installLog: [String] = [] + private(set) var pastPointOfNoReturn = false + private(set) var lastLogFile: URL? + + let directory = IPSWLibrary.defaultDirectory() + private var cacheDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Caches/\(ToolkitVersion.applicationName)/Firmware") + } + private var logDirectory: URL { directory.appendingPathComponent("Logs") } + + // MARK: Helpers + + func helper(_ helper: RestoreHelper) -> URL? { + do { + let url = try helper.locate() + helperProblem = nil + return url + } catch { + helperProblem = (error as? ToolkitError)?.message ?? error.localizedDescription + return nil + } + } + + // MARK: Apple's firmware + + func loadCatalog(productType: String, app: AppModel, force: Bool = false) async { + guard !isLoadingCatalog, force || releasesProductType != productType else { return } + isLoadingCatalog = true + defer { isLoadingCatalog = false } + catalogError = nil + let cache = cacheDirectory.appendingPathComponent("version.plist") + if force { try? FileManager.default.removeItem(at: cache) } + let result = await app.run("Check Apple's firmware", workspace: .firmware, target: nil, transport: "itunes.apple.com/check/version", presentErrors: false) { _ in + try FirmwareCatalog.releases(fromCatalog: try await FirmwareCatalog.load(cache: cache), productType: productType) + } + if let result { + releases = result + releasesProductType = productType + } else { + catalogError = "Apple's firmware list could not be loaded. Check the internet connection and try again." + } + } + + /// Asks Apple whether it signs `release` for this model. Only the firmware's build manifest is + /// read (not the whole IPSW); the request uses a random device ID. + func checkSigning(_ release: FirmwareRelease, deviceClass: String?, app: AppModel) async { + guard !checkingSigning.contains(release.build) else { return } + checkingSigning.insert(release.build) + defer { checkingSigning.remove(release.build) } + let status = await app.run("Check signing for \(release.version)", workspace: .firmware, target: nil, transport: "gs.apple.com (TSS)", presentErrors: false) { _ in + let manifest = try FirmwareManifest.parse(try await RemoteArchive.file(named: FirmwareManifest.fileName, in: release.url)) + guard let identity = manifest.identity(deviceClass: deviceClass) ?? manifest.identity() else { + return FirmwareSigning.Status.unknown("The firmware has no install for this device model.") + } + return await FirmwareSigning.check(identity: identity) + } + signing[release.build] = status ?? .unknown("The firmware's build manifest could not be read from Apple's server.") + } + + func checkSigning(_ file: IPSWFile, deviceClass: String?, app: AppModel) async { + guard !checkingSigning.contains(file.id) else { return } + checkingSigning.insert(file.id) + defer { checkingSigning.remove(file.id) } + guard let identity = file.manifest.identity(deviceClass: deviceClass) ?? file.manifest.identity() else { + signing[file.id] = .unknown("The firmware has no install for this device model.") + return + } + let status = await app.run("Check signing for \(file.title)", workspace: .firmware, target: nil, transport: "gs.apple.com (TSS)", presentErrors: false) { _ in + await FirmwareSigning.check(identity: identity) + } + signing[file.id] = status ?? .unknown("Apple's signing server could not be reached.") + } + + // MARK: Downloads + + func isDownloaded(_ release: FirmwareRelease) -> Bool { + library.contains { $0.url.lastPathComponent == release.fileName } + } + + func download(_ release: FirmwareRelease, app: AppModel) { + guard downloadTasks[release.build] == nil else { return } + let directory = directory + downloads[release.build] = (0, 0) + downloadTasks[release.build] = Task { + let result = await app.run("Download \(release.fileName)", workspace: .firmware, target: nil, transport: "updates.cdn-apple.com", outputPaths: [directory.appendingPathComponent(release.fileName).path]) { operation in + try await FirmwareDownloader().download(release, to: directory) { written, total in + operation.report("\(ByteFormatting.string(written)) of \(ByteFormatting.string(total))", progress: total > 0 ? Double(written) / Double(total) : nil) + Task { @MainActor in self.downloads[release.build] = (written, total) } + } + } + downloads[release.build] = nil + downloadTasks[release.build] = nil + if let result { + verification[result.path] = "Matches Apple's checksum (SHA-1)." + app.statusMessage = "Downloaded \(release.fileName) and verified it." + await scanLibrary() + } + } + } + + func cancelDownload(_ release: FirmwareRelease) { + downloadTasks[release.build]?.cancel() + } + + // MARK: Library + + func scanLibrary() async { + isScanning = true + defer { isScanning = false } + let directory = directory + library = await Task.detached(priority: .userInitiated) { IPSWLibrary.scan(directory) }.value + if let selectedIPSW, !library.contains(where: { $0.id == selectedIPSW }) { self.selectedIPSW = nil } + } + + /// Copies an IPSW into the library after checking that it is one (a clone on the same volume). + func add(_ url: URL, app: AppModel) async { + let directory = directory + let result = await app.run("Add \(url.lastPathComponent)", workspace: .firmware, target: nil, transport: "Local copy", outputPaths: [directory.appendingPathComponent(url.lastPathComponent).path]) { _ in + try await Task.detached(priority: .userInitiated) { + _ = try IPSWLibrary.inspect(url) + try SecureFileIO.createPrivateDirectory(at: directory) + let destination = directory.appendingPathComponent(url.lastPathComponent) + guard !FileManager.default.fileExists(atPath: destination.path) else { + throw ToolkitError.invalidInput("\(url.lastPathComponent) is already in the library.") + } + try FileManager.default.copyItem(at: url, to: destination) + return destination + }.value + } + if result != nil { + app.statusMessage = "Added \(url.lastPathComponent) to the firmware library." + await scanLibrary() + } + } + + /// SHA-1 and SHA-256 of a library file, compared with Apple's SHA-1 when the build is known. + func verify(_ file: IPSWFile, app: AppModel) async { + guard verifying[file.id] == nil else { return } + verifying[file.id] = 0 + defer { verifying[file.id] = nil } + // Apple's SHA-1 applies only to the exact file Apple lists (same name), not to another + // model's IPSW of the same build. + let expected = releases.first { $0.fileName == file.url.lastPathComponent }?.sha1 + let id = file.id + let url = file.url + let sums = await app.run("Verify \(file.url.lastPathComponent)", workspace: .firmware, target: nil, transport: "SHA-1 / SHA-256 (CryptoKit)") { operation in + try await Task.detached(priority: .userInitiated) { + try IPSWLibrary.checksums(of: url) { fraction in + operation.report("Reading the firmware", progress: fraction) + Task { @MainActor in self.verifying[id] = fraction } + } + }.value + } + guard let sums else { return } + if let expected { + verification[id] = sums.sha1 == expected + ? "Matches Apple's checksum (SHA-1 \(sums.sha1))." + : "Does not match Apple's checksum. Expected SHA-1 \(expected), got \(sums.sha1). Delete it and download it again." + } else { + verification[id] = "SHA-1 \(sums.sha1)\nSHA-256 \(sums.sha256)\nApple's checksum for this build is not in the firmware list, so compare these with a trusted source." + } + } + + func moveToTrash(_ file: IPSWFile, app: AppModel) async { + do { + try FileManager.default.trashItem(at: file.url, resultingItemURL: nil) + app.statusMessage = "Moved \(file.url.lastPathComponent) to the Trash." + } catch { + app.present(ToolkitError.fileSystem("\(file.url.lastPathComponent) could not be moved to the Trash.", path: file.url.path)) + } + await scanLibrary() + } + + // MARK: Recovery and DFU + + /// Looks for a device in recovery or DFU mode every few seconds while the page is open. + func watchRecovery(runner: CommandRunning) async { + guard let helper = helper(.irecovery) else { return } + while !Task.isCancelled { + if !isInstalling { + recoveryDevice = await RecoveryProbe.query(runner: runner, helper: helper) + } + try? await Task.sleep(for: .seconds(3)) + } + } + + func enterRecovery(_ device: Device, app: AppModel) async { + let target = device.target + if await app.run("Enter recovery mode", workspace: .firmware, target: target, transport: "lockdown EnterRecovery", { _ in + try await DeviceSession.with(target) { try await $0.enterRecovery() } + return true + }) != nil { + app.statusMessage = "\(device.name) is restarting into recovery mode. It appears here in a few seconds." + } + } + + func exitRecovery(_ recovery: RecoveryDevice, app: AppModel) async { + guard let helper = helper(.irecovery) else { return } + let runner = app.runner + let target = FirmwareDevice(recovery).target + if await app.run("Exit recovery mode", workspace: .firmware, target: target, transport: "irecovery -n", argv: ["irecovery", "-i", recovery.ecid, "-n"], { _ in + let result = try await runner.run(try RecoveryProbe.exitRecoveryRequest(helper: helper, ecid: recovery.ecid)) + guard result.succeeded else { + throw ToolkitError(.commandFailed, message: "The device did not leave recovery mode.", recovery: "If it keeps returning to recovery mode, its system needs to be installed again: use Update first, then Restore.", technicalDetail: result.technicalSummary) + } + return true + }) != nil { + app.statusMessage = "The device is restarting normally." + recoveryDevice = nil + } + } + + // MARK: Installing + + var selectedFile: IPSWFile? { library.first { $0.id == selectedIPSW } } + + func resetPreflight() { preflight = [] } + + /// Checks the model, the build identity, Apple's signing, and that idevicerestore finds the + /// device (`--no-action`). Nothing on the device is changed. + func runPreflight(_ device: FirmwareDevice, app: AppModel) async { + guard let file = selectedFile, let helper = helper(.idevicerestore) else { return } + isPreflighting = true + defer { isPreflighting = false } + var checks = FirmwarePreflight.localChecks(ipsw: file, productType: device.productType, deviceClass: device.deviceClass, mode: mode) + preflight = checks + [FirmwarePreflight.Check(title: "Signed by Apple", passed: nil, detail: "Checking…"), FirmwarePreflight.Check(title: "Device found by the installer", passed: nil, detail: "Checking…")] + await checkSigning(file, deviceClass: device.deviceClass, app: app) + checks.append(FirmwarePreflight.signingCheck(signing[file.id] ?? .unknown("Not checked."))) + preflight = checks + [FirmwarePreflight.Check(title: "Device found by the installer", passed: nil, detail: "Checking…")] + if case .demo = device.state { + checks.append(FirmwarePreflight.Check(title: "Device found by the installer", passed: false, detail: "Demo Mode has no device.")) + } else { + checks.append(await detect(device, file: file, helper: helper, app: app)) + } + preflight = checks + } + + private func detect(_ device: FirmwareDevice, file: IPSWFile, helper: URL, app: AppModel) async -> FirmwarePreflight.Check { + let runner = app.runner + guard let paths = try? self.paths() else { return FirmwarePreflight.Check(title: "Device found by the installer", passed: false, detail: "The firmware folder could not be created.") } + let mode = mode + let output = LockedValue<[String]>([]) + let request: CommandRequest + do { + request = try FirmwareInstall.request(helper: helper, ipsw: file.url, mode: mode, target: device.installTarget, cacheDirectory: paths.cache, logFile: paths.log, preflightOnly: true) + } catch { + return FirmwarePreflight.Check(title: "Device found by the installer", passed: false, detail: (error as? ToolkitError)?.message ?? error.localizedDescription) + } + let result = await app.run("Firmware preflight", workspace: .firmware, target: device.target, transport: "idevicerestore --no-action", argv: request.arguments, outputPaths: [paths.log.path], presentErrors: false) { _ in + try await FirmwareInstall.run(request, runner: runner, progress: { _, _ in }, line: { line in output.withLock { $0.append(line) } }) + } + lastLogFile = paths.log + if result != nil { + let found = output.current.last { $0.localizedCaseInsensitiveContains("found device in") || $0.localizedCaseInsensitiveContains("mode") } + return FirmwarePreflight.Check(title: "Device found by the installer", passed: true, detail: found ?? "idevicerestore found the device.") + } + return FirmwarePreflight.Check(title: "Device found by the installer", passed: false, detail: FirmwareInstall.failureReason(output: output.current.joined(separator: "\n"))) + } + + private func paths() throws -> (cache: URL, log: URL) { + let cache = cacheDirectory.appendingPathComponent("idevicerestore") + try SecureFileIO.createPrivateDirectory(at: cache) + try SecureFileIO.createPrivateDirectory(at: logDirectory) + let stamp = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate, .withTime]) + return (cache, logDirectory.appendingPathComponent("idevicerestore-\(stamp).log")) + } + + /// Installs the selected firmware. Stop works until the system starts being written; after + /// that the install always runs to the end, because stopping would leave the device unusable. + func install(_ device: FirmwareDevice, app: AppModel) async { + guard let file = selectedFile, let helper = helper(.idevicerestore), !isInstalling else { return } + let request: CommandRequest + let logFile: URL + do { + let paths = try paths() + logFile = paths.log + request = try FirmwareInstall.request(helper: helper, ipsw: file.url, mode: mode, target: device.installTarget, cacheDirectory: paths.cache, logFile: paths.log, preflightOnly: false) + } catch { + app.present(error) + return + } + isInstalling = true + installStep = FirmwareInstall.steps[0] + installFraction = 0 + installLog = [] + pastPointOfNoReturn = false + lastLogFile = logFile + defer { isInstalling = false } + let runner = app.runner + let committed = LockedValue(false) + let result = await app.run("\(mode.title) \(file.title)", workspace: .firmware, target: device.target, transport: "idevicerestore", argv: request.arguments, outputPaths: [logFile.path]) { operation in + let helperTask = Task { + try await FirmwareInstall.run(request, runner: runner, progress: { step, fraction in + if FirmwareInstall.isPastPointOfNoReturn(step: step) { committed.withLock { $0 = true } } + operation.report(step, progress: fraction) + Task { @MainActor in + self.installStep = step + self.installFraction = fraction + if FirmwareInstall.isPastPointOfNoReturn(step: step) { self.pastPointOfNoReturn = true } + } + }, line: { line in + Task { @MainActor in + self.installLog.append(line) + if self.installLog.count > 500 { self.installLog.removeFirst(100) } + } + }) + } + return try await withTaskCancellationHandler { + try await helperTask.value + } onCancel: { + if committed.current { + operation.report("Can't stop now: the system is being written. Keep the device connected.") + } else { + helperTask.cancel() + } + } + } + if result != nil { + installStep = "Done" + installFraction = 1 + app.statusMessage = "\(file.title) was installed on \(device.name). The device restarts and finishes setting up." + } + } + + func revealLog() { + if let lastLogFile { NSWorkspace.shared.activateFileViewerSelecting([lastLogFile]) } + } +} diff --git a/App/iOSDeveloperToolkit/Views/ContentView.swift b/App/iOSDeveloperToolkit/Views/ContentView.swift index 53179bc..a4ef918 100644 --- a/App/iOSDeveloperToolkit/Views/ContentView.swift +++ b/App/iOSDeveloperToolkit/Views/ContentView.swift @@ -62,6 +62,7 @@ struct WorkspaceView: View { case .overview: OverviewView() case .device: DeviceDetailView() case .developerImage: DeveloperImageView() + case .firmware: FirmwareView() case .readiness: ReadinessView() case .apps: AppsView() case .installApp: InstallAppView() diff --git a/App/iOSDeveloperToolkit/Views/FirmwareView.swift b/App/iOSDeveloperToolkit/Views/FirmwareView.swift new file mode 100644 index 0000000..f2b3713 --- /dev/null +++ b/App/iOSDeveloperToolkit/Views/FirmwareView.swift @@ -0,0 +1,405 @@ +import DeviceKit +import SwiftUI +import ToolkitCore +import ToolkitFeatures + +// MARK: - Firmware + +/// Firmware (IPSW) for iPhone and iPad: the device and its mode, Apple's firmware and whether +/// Apple signs it, the local library, and installing with the bundled idevicerestore. +struct FirmwareView: View { + @Environment(AppModel.self) private var model + @State private var confirmation: PendingConfirmation? + + /// A device in recovery or DFU mode comes first: it is the one the installer will find. + private var device: FirmwareDevice? { + if let recovery = model.firmware.recoveryDevice { return FirmwareDevice(recovery) } + if let selected = model.selectedDevice, selected.kind == .physical || selected.kind == .demo { return FirmwareDevice(selected) } + return nil + } + + var body: some View { + let firmware = model.firmware + WorkspacePage(workspace: .firmware) { + if let problem = firmware.helperProblem { + Label(problem, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + deviceCard + if let device, let productType = device.productType { + appleFirmware(device, productType: productType) + } + libraryCard + installCard + about + } + .sheet(item: $confirmation) { pending in + ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: pending.commandPreview, onConfirm: pending.action) + } + .task { await firmware.scanLibrary() } + .task { await firmware.watchRecovery(runner: model.runner) } + .task(id: device?.productType) { + if let device, let productType = device.productType, device.state != .demo { + await firmware.loadCatalog(productType: productType, app: model) + } + } + .onChange(of: firmware.selectedIPSW) { firmware.resetPreflight() } + .onChange(of: firmware.mode) { firmware.resetPreflight() } + } + + // MARK: Device + + private var deviceCard: some View { + let firmware = model.firmware + return Card(title: "Device", systemImage: "iphone", subtitle: "Connect the iPhone or iPad by USB. A device in recovery or DFU mode appears here on its own.") { + if let device { + InfoRow("Device", device.name) + InfoRow("Model", device.productType ?? "Unknown", monospaced: true) + if let deviceClass = device.deviceClass { InfoRow("Board", deviceClass.uppercased(), monospaced: true) } + InfoRow("Mode", device.modeLabel) + if case .recovery(let recovery) = device.state { + InfoRow("ECID", recovery.ecid, monospaced: true) + } + HStack { + switch device.state { + case .normal: + if let selected = model.selectedDevice { + Button("Enter Recovery Mode") { + confirmation = PendingConfirmation(title: "Enter recovery mode", detail: "\(selected.name) restarts into recovery mode. Nothing is erased. To leave recovery mode, use Exit Recovery Mode here.", requirement: .make(for: .deviceChange, target: selected.target), target: selected.target) { + Task { await firmware.enterRecovery(selected, app: model) } + } + } + .disabled(firmware.isInstalling || !selected.supportsLockdownServices) + } + case .recovery(let recovery): + Button("Exit Recovery Mode") { + confirmation = PendingConfirmation(title: "Exit recovery mode", detail: "The device restarts normally. If its system is damaged it returns to recovery mode; then install firmware with Update or Restore.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { + Task { await firmware.exitRecovery(recovery, app: model) } + } + } + .disabled(firmware.isInstalling || recovery.mode != .recovery) + case .demo: + Text("Demo Mode shows the page without a device. Nothing can be installed.").font(.callout).foregroundStyle(.secondary) + } + } + } else if model.selectedDevice?.kind == .simulator { + Text("Simulators do not use firmware. Choose an iPhone or iPad, or connect one in recovery or DFU mode.") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + } else { + Text("No iPhone or iPad found. Connect one by USB, unlock it, and tap Trust — or put it in recovery or DFU mode.") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + } + DisclosureGroup("How to put a device in DFU mode") { + VStack(alignment: .leading, spacing: 6) { + Text("DFU mode is needed only when recovery mode does not work. Connect the device to this Mac first. For iPhone 8 and later, and iPad without a Home button:") + Text("1. Press and release Volume Up, then press and release Volume Down.") + Text("2. Press and hold the Side (or Top) button until the screen goes black.") + Text("3. Keep holding it and also hold Volume Down for 5 seconds.") + Text("4. Release the Side (or Top) button and keep holding Volume Down for 10 more seconds.") + Text("In DFU mode the screen stays black. If the Apple logo appears, it restarted normally: try again. The device appears on this page as \u{201C}DFU mode\u{201D}; install with Restore. To leave DFU mode, force-restart it (Volume Up, Volume Down, then hold the Side button until the Apple logo).") + } + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + } + .font(.callout) + } + } + + // MARK: Apple's firmware + + private func appleFirmware(_ device: FirmwareDevice, productType: String) -> some View { + let firmware = model.firmware + return Card(title: "Apple's firmware for \(productType)", systemImage: "icloud.and.arrow.down", subtitle: "From Apple's firmware list, which offers the current firmware for each model. Signing status comes from Apple's signing server, asked with a random device ID.") { + if firmware.isLoadingCatalog { + ProgressView().controlSize(.small) + } else if let error = firmware.catalogError { + Label(error, systemImage: "wifi.exclamationmark").font(.callout).foregroundStyle(.orange) + } else if firmware.releasesProductType == productType && firmware.releases.isEmpty { + Text("Apple lists no firmware for this model.").font(.callout).foregroundStyle(.secondary) + } + ForEach(firmware.releasesProductType == productType ? firmware.releases : []) { release in + releaseRow(release, device: device) + Divider() + } + HStack { + Button(firmware.releasesProductType == productType ? "Check Again" : "Check Apple's Firmware") { + Task { await firmware.loadCatalog(productType: productType, app: model, force: firmware.releasesProductType == productType) } + } + .disabled(firmware.isLoadingCatalog) + } + } + } + + private func releaseRow(_ release: FirmwareRelease, device: FirmwareDevice) -> some View { + let firmware = model.firmware + let progress = firmware.downloads[release.build] + return VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("\(release.version) (\(release.build))").font(.callout.weight(.semibold)) + Text(release.fileName).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) + } + Spacer() + SigningBadge(status: firmware.signing[release.build], checking: firmware.checkingSigning.contains(release.build)) + } + if let sha1 = release.sha1 { InfoRow("Apple's SHA-1", sha1, monospaced: true) } + if let progress { + ProgressView(value: progress.total > 0 ? Double(progress.written) / Double(progress.total) : nil) { + Text(progress.total > 0 ? "\(ByteFormatting.string(progress.written)) of \(ByteFormatting.string(progress.total))" : "Starting…").font(.caption) + } + } + HStack { + Button("Check Signing") { Task { await firmware.checkSigning(release, deviceClass: device.deviceClass, app: model) } } + .disabled(firmware.checkingSigning.contains(release.build)) + if firmware.isDownloaded(release) { + Label("In the library", systemImage: "checkmark.circle").font(.callout).foregroundStyle(.green) + } else if progress != nil { + Button("Stop Download") { firmware.cancelDownload(release) } + .help("The download continues where it stopped next time.") + } else { + Button("Download") { firmware.download(release, app: model) } + .help("Downloads the IPSW to the firmware library and checks it against Apple's SHA-1.") + } + } + } + } + + // MARK: Library + + private var libraryCard: some View { + let firmware = model.firmware + let productType = device?.productType + return Card(title: "Firmware library", systemImage: "externaldrive", subtitle: "IPSW files on this Mac, in \(firmware.directory.path).") { + if firmware.isScanning && firmware.library.isEmpty { + ProgressView().controlSize(.small) + } else if firmware.library.isEmpty { + Text("No firmware yet. Download one above, or add an .ipsw file.").font(.callout).foregroundStyle(.secondary) + } + ForEach(firmware.library) { file in + libraryRow(file, productType: productType) + Divider() + } + HStack { + Button("Add IPSW…") { + if let url = FilePanels.chooseFile(title: "Choose an iPhone or iPad firmware (.ipsw)", allowedExtensions: ["ipsw"]) { + Task { await firmware.add(url, app: model) } + } + } + Button("Show Library in Finder") { + try? SecureFileIO.createPrivateDirectory(at: firmware.directory) + FilePanels.reveal(firmware.directory) + } + Button("Rescan") { Task { await firmware.scanLibrary() } } + .disabled(firmware.isScanning) + } + } + } + + private func libraryRow(_ file: IPSWFile, productType: String?) -> some View { + let firmware = model.firmware + let matches = productType.map(file.supports(productType:)) + return VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(file.title).font(.callout.weight(.semibold)) + Text("\(file.url.lastPathComponent) · \(ByteFormatting.string(Int64(file.size))) · \(file.manifest.supportedProductTypes.count) models") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + if matches == false { + Label("Not for this device", systemImage: "xmark.circle").font(.caption).foregroundStyle(.orange) + } + SigningBadge(status: firmware.signing[file.id], checking: firmware.checkingSigning.contains(file.id)) + } + if let fraction = firmware.verifying[file.id] { + ProgressView(value: fraction) { Text("Verifying").font(.caption) } + } else if let result = firmware.verification[file.id] { + Text(result).font(.caption.monospaced()).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + } + HStack { + Button("Check Signing") { Task { await firmware.checkSigning(file, deviceClass: device?.deviceClass, app: model) } } + .disabled(firmware.checkingSigning.contains(file.id)) + Button("Verify") { Task { await firmware.verify(file, app: model) } } + .disabled(firmware.verifying[file.id] != nil) + .help("Computes SHA-1 and SHA-256 and compares them with Apple's checksum") + Button("Show in Finder") { FilePanels.reveal(file.url) } + Button("Move to Trash") { + confirmation = PendingConfirmation(title: "Move \(file.url.lastPathComponent) to the Trash", detail: "The file stays in the Trash until you empty it.", requirement: .make(for: .hostWrite, target: nil), target: nil) { + Task { await firmware.moveToTrash(file, app: model) } + } + } + .disabled(firmware.isInstalling && firmware.selectedIPSW == file.id) + } + } + } + + // MARK: Install + + private var installCard: some View { + let firmware = model.firmware + let productType = device?.productType + let candidates = firmware.library.filter { productType == nil || $0.supports(productType: productType!) } + return Card(title: "Install firmware", systemImage: "arrow.down.to.line.circle", subtitle: "Installs an IPSW from the library with idevicerestore, bundled with the app.") { + Picker("Firmware", selection: Binding(get: { firmware.selectedIPSW }, set: { firmware.selectedIPSW = $0 })) { + Text("Choose…").tag(String?.none) + ForEach(candidates) { file in + Text("\(file.title) — \(file.url.lastPathComponent)").tag(Optional(file.id)) + } + } + .disabled(firmware.isInstalling) + Picker("Install", selection: Binding(get: { firmware.mode }, set: { firmware.mode = $0 })) { + ForEach(FirmwareInstall.Mode.allCases, id: \.self) { mode in + Text(mode.title + (mode == .update ? " (keep data)" : " (erase)")).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(firmware.isInstalling) + Text(firmware.mode.explanation).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 4) { + Label("Keep the device connected by USB until it finishes; it restarts several times.", systemImage: "cable.connector") + Label("An internet connection is needed: Apple signs the firmware for this device during the install.", systemImage: "network") + Label("Back up first. Restore erases everything, and an update that fails can require a restore.", systemImage: "externaldrive.badge.timemachine") + Label("After a restore the device may ask for the Apple Account it was set up with (Activation Lock).", systemImage: "lock") + } + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + + if !firmware.preflight.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(firmware.preflight) { check in + HStack(alignment: .firstTextBaseline) { + Image(systemName: check.passed == true ? "checkmark.circle.fill" : check.passed == false ? "xmark.circle.fill" : "questionmark.circle") + .foregroundStyle(check.passed == true ? .green : check.passed == false ? .red : .secondary) + Text(check.title).font(.callout.weight(.semibold)) + Text(check.detail).font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + } + } + } + + if firmware.isInstalling || firmware.installStep != nil { + progressView + } + + HStack { + if let device { + Button("Check Before Installing") { Task { await firmware.runPreflight(device, app: model) } } + .disabled(firmware.selectedFile == nil || firmware.isPreflighting || firmware.isInstalling || firmware.helperProblem != nil) + .help("Checks the model, Apple's signing, and that the installer finds the device. Nothing on the device changes.") + Button("\(firmware.mode.title)…") { confirmInstall(device) } + .disabled(firmware.selectedFile == nil || firmware.isInstalling || device.state == .demo || firmware.helperProblem != nil) + if firmware.isPreflighting { ProgressView().controlSize(.small) } + } + Spacer() + if firmware.lastLogFile != nil { + Button("Show Log") { firmware.revealLog() } + } + } + } + } + + private var progressView: some View { + let firmware = model.firmware + return VStack(alignment: .leading, spacing: 6) { + ProgressView(value: firmware.installFraction) { + Text(firmware.installStep ?? "Starting…").font(.callout.weight(.semibold)) + } + if firmware.pastPointOfNoReturn && firmware.isInstalling { + Label("The system is being written. It can't be stopped now — keep the device connected.", systemImage: "exclamationmark.octagon") + .font(.callout) + .foregroundStyle(.red) + } + if !firmware.installLog.isEmpty { + DisclosureGroup("Installer output") { + ScrollView { + Text(firmware.installLog.suffix(200).joined(separator: "\n")) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 200) + } + .font(.callout) + } + } + } + + private func confirmInstall(_ device: FirmwareDevice) { + let firmware = model.firmware + guard let file = firmware.selectedFile else { return } + let restore = firmware.mode == .restore + let failed = firmware.preflight.filter { $0.passed == false } + var lines = [restore + ? "\(device.name) will be erased and \(file.title) installed. Everything on it is deleted." + : "\(file.title) will be installed on \(device.name). Apps, settings, and data are kept."] + lines.append("Stop works only until the system starts being written. After that, stopping would leave the device unusable, so the install always finishes.") + lines.append("Apple's signing server receives the device's chip, board, and ECID to sign the firmware for it, as Finder does.") + if firmware.preflight.isEmpty { + lines.append("Tip: Check Before Installing first.") + } else if !failed.isEmpty { + lines.append("The check found problems: " + failed.map(\.title).joined(separator: ", ") + ".") + } + confirmation = PendingConfirmation(title: restore ? "Erase and restore \(device.name)" : "Update \(device.name)", detail: lines.joined(separator: "\n\n"), requirement: .make(for: restore ? .highImpact : .deviceChange, target: device.target), target: device.target) { + Task { await firmware.install(device, app: model) } + } + } + + private var about: some View { + Card(title: "About firmware installation", systemImage: "info.circle") { + VStack(alignment: .leading, spacing: 8) { + Text("Apple signs each firmware for each device when it is installed, and only while Apple still signs that version. Firmware Apple no longer signs cannot be installed, by this app or any other.") + Text("Update installs over the current system and keeps data, like Finder's Update. Restore erases the device first, like Finder's Restore. A device in DFU mode can only be restored.") + Text("Installation uses idevicerestore and libirecovery from the libimobiledevice project (LGPL), bundled with the app as separate programs. Their licenses and sources are listed in Third-Party Notices.") + } + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +/// Apple's signing status for one firmware. +struct SigningBadge: View { + let status: FirmwareSigning.Status? + let checking: Bool + + var body: some View { + if checking { + ProgressView().controlSize(.small) + } else if let status { + Label(status.label, systemImage: symbol(status)) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .foregroundStyle(color(status)) + .background(color(status).opacity(0.12), in: Capsule()) + .help(status.explanation) + } else { + Text("Signing not checked").font(.caption).foregroundStyle(.secondary) + } + } + + private func symbol(_ status: FirmwareSigning.Status) -> String { + switch status { + case .signed: return "checkmark.seal" + case .notSigned: return "xmark.seal" + case .unknown: return "questionmark.circle" + } + } + + private func color(_ status: FirmwareSigning.Status) -> Color { + switch status { + case .signed: return .green + case .notSigned: return .red + case .unknown: return .orange + } + } +} diff --git a/App/iOSDeveloperToolkit/Views/ReferenceViews.swift b/App/iOSDeveloperToolkit/Views/ReferenceViews.swift index 6250038..3215b29 100644 --- a/App/iOSDeveloperToolkit/Views/ReferenceViews.swift +++ b/App/iOSDeveloperToolkit/Views/ReferenceViews.swift @@ -153,13 +153,15 @@ struct SafetyView: View { Card(title: "What this app is", systemImage: "checkmark.shield") { bullet("A macOS workbench for authorized development, testing, diagnostics, backup, and evidence preservation on devices you own or are allowed to examine.") bullet("It uses Apple's own interfaces: the macOS device service (usbmuxd and lockdown), Xcode's CoreDevice, simctl, and Instruments.") + bullet("Firmware installation is the one exception: it uses idevicerestore and irecovery from the open-source libimobiledevice project, bundled with the app as separate programs.") bullet("It runs without administrator rights and never uses sudo, never reads /var/db/lockdown, and never restarts system services.") } Card(title: "What it does not do", systemImage: "xmark.shield") { bullet("No jailbreak, passcode bypass, sandbox escape, code-signing bypass, or decryption of protected data or traffic.") bullet("Developer services (the Developer Disk Image) do not grant root access or unrestricted file system access.") bullet("AFC and CoreDevice file views are Apple-defined windows onto specific areas, not full file system acquisitions.") - bullet("No one-click erase, restore, activation, or supervision. The only restart is a separately confirmed high-impact action.") + bullet("No one-click erase, restore, activation, or supervision. Restarting a device and restoring firmware are separately confirmed high-impact actions.") + bullet("No firmware downgrades or exploits: only firmware Apple currently signs for the device can be installed.") } Card(title: "How changes are confirmed", systemImage: "hand.raised") { bullet("Read-only actions run immediately.") diff --git a/MIGRATION.md b/MIGRATION.md index 4e363e9..80b6775 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -471,3 +471,54 @@ and 0.3.x's shortcuts for the tenth and later pages and focus (⌘0, ⇧⌘E/M/S | G11 | Keyboard shortcut reference (⌘/) and previous/next workspace (⌥⌘← / ⌥⌘→) | P3 | ✅ resolved — Help › Keyboard Shortcuts; View › Previous/Next Workspace | | G12 | “Use in Advanced Mode” from Tool Reference; readiness shortcuts on Actions and Evidence | P3 | ✅ resolved | | G13 | UFADE developer-image submodule status | P3 | ✅ resolved — shown after Validate | + +## 10. Firmware: IPSW Manager and installation + +The Python app had no firmware feature; this is new in the Swift app. + +### 10.1 Decision: bundle idevicerestore (a documented exception) + +The app otherwise uses only Apple mechanisms and its own Swift clients, and never shells out to +third-party tools. Firmware installation is the exception, by the maintainer's decision: Apple +provides no public restore API, and reimplementing the restore protocol (iBoot, DFU, ASR, the +restored daemon, baseband and co-processor updates) would be large and risky for devices. The app +instead bundles `idevicerestore` and `irecovery` from the libimobiledevice project: + +- built by `scripts/build-restore-helpers.sh` from pinned commits (libplist, libimobiledevice-glue, + libusbmuxd, libtatsu, libimobiledevice, libirecovery, idevicerestore, libzip) and a + checksum-verified OpenSSL 3.5.8 tarball — universal, statically linked, depending only on macOS + system libraries (the script and the release both check this); +- shipped as separate programs in `Contents/Helpers`, signed with the hardened runtime, run only + through `CommandRunner` with argument vectors and a minimal environment; never linked; +- LGPL: license files and `SOURCES.txt` in the app, and each release attaches the complete source; +- never used for exploits: `irecovery -k` and idevicerestore's `--pwn` are not exposed. + +Everything that does not need the restore protocol is native Swift (`Sources/ToolkitFeatures/Firmware`): + +| Part | How | +|---|---| +| Apple's firmware list | `https://itunes.apple.com/check/version` (Finder's list), cached for a day; current firmware per model with Apple's SHA-1 | +| Build manifest of a remote IPSW | HTTP range requests against Apple's CDN (ZIP64 central directory, then only `BuildManifest.plist`) — about 0.3 s instead of downloading 8+ GB | +| Signing status | A TSS request to `gs.apple.com` for the model's erase identity, built as libtatsu builds the AP request (no baseband ticket; skipped components; request rules; `Ap,*` identity values), with a random ECID and nonce. STATUS 0 = signed, 94 = not signed | +| Downloads | `URLSessionDownloadTask` with resume data kept next to the file; kept only when the SHA-1 matches Apple's | +| Library | IPSWs are read with the app's own ZIP reader (now with ZIP64); SHA-1 and SHA-256 with CryptoKit | +| Recovery mode | Enter: lockdown `EnterRecovery` (native). Detect: `irecovery -q` every 3 s while the page is open. Exit: `irecovery -i ECID -n` | +| Install | `idevicerestore --plain-progress --no-input --cache-path … --logfile … --udid/--ecid … [--erase] IPSW`; progress steps parsed from `progress: `; Stop is ignored once the system is being written | +| Check Before Installing | Model and install type from the manifest, Apple signing, and `idevicerestore --no-action` (finds the device, changes nothing) | + +### 10.2 Verification + +- Unit tests (`FirmwareTests`, 14): catalog parsing and caching, manifest parsing and identity + choice, the TSS request (components, rules, skipped baseband, copied identity values) and + reply handling, remote manifest reading against a fake range server, the library and checksums, + `irecovery -q` parsing, install command vectors (no shell, validated UDID/ECID), progress and + failure parsing, the streaming install runner, and the preflight checks. +- Network tests (`RealFirmwareTests`, opt-in with `IDT_NETWORK_TESTS=1`), run 2026-09-29: Apple's + list gives iPhone18,1 → 27.0.1 (24A446); its manifest is read from Apple's CDN by range + requests; Apple's signing server answers **Signed** for it. The helpers run (`idevicerestore + 1.0.0-git-60192e9`, `irecovery 1.3.1`), including the x86_64 slices under Rosetta. +- App: builds with zero warnings; the Firmware page renders (checked in Demo Mode). + +**Not verified on hardware:** entering or leaving recovery mode, DFU detection, Check Before +Installing against a device, and Update or Restore on a real device. These need a device that +can be erased and are left for a supervised test. diff --git a/README.md b/README.md index f4eb24d..6630c58 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Instruments. It needs no Python, no Homebrew packages, and no administrator righ - [First steps](#first-steps) - [Workspaces](#workspaces) - [Developer images](#developer-images) +- [Firmware](#firmware) - [Command-line tool](#command-line-tool) - [Security and privacy](#security-and-privacy) - [Troubleshooting](#troubleshooting) @@ -47,6 +48,7 @@ Instruments. It needs no Python, no Homebrew packages, and no administrator righ | **Readiness Check** | A read-only check of every prerequisite (Xcode, the macOS device service, connection, trust, Developer Mode, Xcode's device service, developer services, Instruments, lock state, logging and backup services, Safari Web Inspector) with a next step for anything not ready. | | **Live Logs** | Unified Logging and classic syslog streamed from physical devices, and the simulator's unified log; plus two collected sources: an **OSLog archive** (the device's saved log history for a time window, kept as a `.logarchive` for Console) and **DVT logging** (os_log recorded through Instruments, kept as a `.trace`). Every byte is spooled and hashed; the view can be paused and filtered (literal or regex) without affecting capture. Mark findings, then export the raw capture, filtered lines, or an evidence bundle. | | **Location Lab** | Set a coordinate (offline world map, map-link parsing, nudges, saved places), move along a route at constant speed, or replay a GPX track. Always clearable; every change is logged. | +| **Firmware** | Apple's firmware (IPSW) for the connected model with whether Apple still signs it; download with resume and SHA-1 verification; a local IPSW library; recovery and DFU mode; and Update (keeps data) or Restore (erases) with the bundled `idevicerestore`, after a check that changes nothing. | | **Apps** | Search and sort installed apps (with sizes over USB), launch, and remove with confirmation. | | **Install App** | Inspect an `.ipa` on the Mac first — contents, provisioning profile, and code signature verified with Security.framework — then install it on a device, or install an `.app` on a simulator. | | **Actions** | Over 40 guided actions (diagnostics, battery, IORegistry, provisioning and configuration profiles, crash reports, screenshots, sysdiagnose, Instruments recordings, packet capture, Bluetooth capture, Safari and web view tabs, network discovery, launch, open URL, simulated location, restart, simulator controls), each showing its risk, what it needs, and exactly how it runs. An Advanced Mode runs `devicectl` subcommands bound to the selected device. | @@ -166,6 +168,9 @@ No device handy? Turn on **Device › Demo Mode** to explore every workspace wit - **Developer Image** — the developer image on the selected device (state, details, mount, unmount), how to mount it (automatic, Xcode's device service, or the built-in client), the images on this Mac and folders you add, and what is mounted on the device. +- **Firmware** — the device and its mode (normal, recovery, or DFU), Apple's firmware for it and + its signing status, downloads, the IPSW library (add, check signing, verify, show in Finder, + move to the Trash), and installation. - **Readiness Check** — the read-only prerequisite check, a copyable report, and a local history of tested devices that can be exported as sanitized JSON or Markdown. - **Apps** — installed apps with search, sort, sizes, launch, and confirmed removal. @@ -238,6 +243,36 @@ when it can reach the device on iOS 17 and later, and otherwise the built-in cli to the device's image-mounter service over USB and works without Xcode's device service. The app never downloads images from third parties. +## Firmware + +The **Firmware** page (sidebar, under Device) installs iPhone and iPad firmware (IPSW files) the +way Finder does, using `idevicerestore` and `irecovery` from the +[libimobiledevice](https://libimobiledevice.org) project, bundled with the app as separate programs +(see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)). + +- **Device and mode.** The selected iPhone or iPad, or a device in recovery or DFU mode (found + every few seconds while the page is open). **Enter Recovery Mode** and **Exit Recovery Mode**, + and step-by-step DFU instructions. +- **Apple's firmware.** Apple's firmware list (`itunes.apple.com/check/version`, the one Finder + uses) gives the current firmware for the model, with Apple's SHA-1. **Check Signing** asks + Apple's signing server whether it still signs that build — reading only the build manifest from + Apple's server, and sending a random device ID, never the device's. **Download** saves the IPSW + to the library, continues an interrupted download where it stopped, and keeps the file only if + its SHA-1 matches Apple's. +- **Library.** IPSW files in `~/Library/Application Support/iOS Developer Toolkit (Swift)/Firmware`. + Add your own, check signing, verify (SHA-1 and SHA-256), show in Finder, or move to the Trash. +- **Install.** Choose an IPSW and **Update** (keeps apps and data) or **Restore** (erases the + device). **Check Before Installing** confirms the firmware is for this model, has the right + install type, is signed by Apple, and that the installer finds the device — without changing + anything. Update needs the typed `RUN` confirmation; Restore is high impact. Progress is shown + step by step; **Stop** works until the system starts being written, after which the install + always finishes, since stopping then would leave the device unusable. Logs are kept in the + library's `Logs` folder. + +During an install, Apple's signing server receives the device's chip, board, and ECID to sign +the firmware for it, as with Finder. Firmware that Apple no longer signs cannot be installed. +The app does not offer jailbreak-style exploits or downgrades. + ## Command-line tool `idt` provides the automation-friendly parts of the app: @@ -273,7 +308,9 @@ device could not be identified. and XML entities are rejected. - **Local only.** Nothing is uploaded, with one confirmed exception: mounting a developer image on iOS 17 and later asks Apple's signing server (`gs.apple.com`) to personalize it, sending the - device's chip, board, and ECID with a one-time nonce — as Xcode does. Captures, backups, cases, and reports are written with + device's chip, board, and ECID with a one-time nonce — as Xcode does. Installing firmware sends + the same kind of request, as Finder does; checking whether Apple signs a firmware uses a random + device ID. The Firmware page reads Apple's firmware list and downloads IPSWs from Apple. Captures, backups, cases, and reports are written with owner-only permissions. The app's own log records outcomes rather than device content, and marks identifiers as private. - **Sanitized sharing.** **iOS Developer Toolkit › Create Support Bundle…** and the readiness @@ -291,6 +328,7 @@ device could not be identified. | Developer features say they need Xcode | Install Xcode, open it once, and check *Xcode › Settings › Locations › Command Line Tools*. | | The developer image will not mount | Read the Developer Image page: it names the problem (Developer Mode, lock, missing or incompatible image) and the fix. On iOS 17 and later keep the Mac online (Apple personalizes the image); if one route fails, switch **How to mount** on the Developer Image page. | | A backup stops with “must stay unlocked” | Unlock the device and keep it awake until the backup finishes. | +| Firmware will not install | Run **Check Before Installing** on the Firmware page. Apple must still sign the firmware; keep the device connected by USB and the Mac online. If the device is left in recovery mode, install again with **Restore**. The log is in the library's `Logs` folder. | | An `.ipa` cannot be installed | Check the inspection: the signature must be valid and the profile must include the device. | | Live logs are very busy | Filter the view or pause it; capture continues in the background. | | Something else | Run the **Readiness Check**, then create a support bundle and open a discussion. | @@ -314,6 +352,16 @@ xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ scripts/build-release.sh # universal, ad-hoc-signed release ZIP, SBOM, checksums in build-output/release/ ``` +The release includes the firmware helpers, which `scripts/build-release.sh` builds with +`scripts/build-restore-helpers.sh` from pinned sources. That needs +`brew install autoconf automake libtool pkg-config cmake`. To try the Firmware page from a +development build, build the helpers once and point the app at them: + +```bash +scripts/build-restore-helpers.sh +open --env IDT_RESTORE_HELPERS="$PWD/build-output/restore-helpers/out/bin" "iOS Developer Toolkit (Swift).app" +``` + The Xcode project is generated from `project.yml` with [XcodeGen](https://github.com/yonaskolb/XcodeGen) and committed, so you only need XcodeGen when you change the project structure (`xcodegen generate`). @@ -321,6 +369,8 @@ Optional test suites: ```bash IDT_SIMULATOR_TESTS=1 swift test --filter RealSimulator # boots a simulator end to end +IDT_NETWORK_TESTS=1 IDT_RESTORE_HELPERS="$PWD/build-output/restore-helpers/out/bin" \ + swift test --filter RealFirmware # Apple's firmware list, signing, the helpers xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ -destination 'platform=macOS' test # UI tests (macOS asks to allow automation) ``` @@ -345,6 +395,10 @@ Version 1.0 is a native rewrite of the Python/PySide6 app ([iOS Developer Toolki profiles exported by 0.3.x can be imported in **Settings › Profiles**; the preview explains how each setting carries over. +Firmware installation (Update, Restore, recovery and DFU mode) has been tested against Apple's +servers and with the bundled helpers, but not yet by installing firmware on a device; see +[MIGRATION.md](MIGRATION.md#10-firmware-ipsw-manager-and-installation). + ### The Python app The Python/PySide6 app, **iOS Developer Toolkit**, is maintained separately in diff --git a/SOURCE_AVAILABILITY.md b/SOURCE_AVAILABILITY.md index b6153c5..d6ce5d8 100644 --- a/SOURCE_AVAILABILITY.md +++ b/SOURCE_AVAILABILITY.md @@ -14,6 +14,13 @@ public repositories at the exact revisions recorded in [`Package.resolved`](Pack that tag. They are listed with their licenses in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and in the SPDX SBOM attached to each release. No binary-only third-party code is included. +The firmware helpers in `Contents/Helpers` (`idevicerestore` and `irecovery`, from the +libimobiledevice project, LGPL) are built by +[`scripts/build-restore-helpers.sh`](scripts/build-restore-helpers.sh) from pinned commits and a +checksum-verified OpenSSL release. Each release attaches their complete corresponding source, with +that script, as `iOS-Developer-Toolkit-Swift-VERSION-firmware-helpers-source.tar.gz`, covered by +`SHA256SUMS.txt`. + Optional external tools (MVT, UFADE, idb Companion) are installed and managed by the user and are not part of the app or its SBOM. diff --git a/Sources/DeviceKit/DeveloperImage/ImagePersonalization.swift b/Sources/DeviceKit/DeveloperImage/ImagePersonalization.swift index 373936e..2c2e217 100644 --- a/Sources/DeviceKit/DeveloperImage/ImagePersonalization.swift +++ b/Sources/DeviceKit/DeveloperImage/ImagePersonalization.swift @@ -72,7 +72,7 @@ public struct PersonalizationIdentifiers: Sendable, Hashable { /// carries the `ApImg4Ticket` the device needs to mount the image. public enum ImagePersonalization { static let logger = ToolkitLog.logger(.networking) - static let clientVersion = "libauthinstall-1033.0.2" + public static let clientVersion = "libauthinstall-1033.0.2" /// Parameters the restore-request rules are evaluated against (a production device). static let ruleParameters: [String: Bool] = [ @@ -82,6 +82,11 @@ public enum ImagePersonalization { ] public static func request(identity: DeveloperImageBuildIdentity, identifiers: PersonalizationIdentifiers, nonce: Data, requestID: UUID = UUID()) -> PlistValue { + request(manifest: identity.manifest, identifiers: identifiers, nonce: nonce, requestID: requestID) + } + + /// The request for any build identity's `Manifest` (developer images and firmware alike). + public static func request(manifest: [String: PlistValue], identifiers: PersonalizationIdentifiers, nonce: Data, requestID: UUID = UUID()) -> PlistValue { var request: [String: PlistValue] = [ "@HostPlatformInfo": "mac", "@VersionInfo": .string(clientVersion), @@ -99,8 +104,8 @@ public enum ImagePersonalization { "UID_MODE": false, ] for (key, value) in identifiers.additional { request[key] = value } - let fallbackRules = identity.manifest["LoadableTrustCache"]?["Info"]?["RestoreRequestRules"]?.arrayValue ?? [] - for (key, item) in identity.manifest { + let fallbackRules = manifest["LoadableTrustCache"]?["Info"]?["RestoreRequestRules"]?.arrayValue ?? [] + for (key, item) in manifest { guard let entry = item.dictionaryValue, let info = entry["Info"], entry["Trusted"]?.boolValue == true else { continue } var tssEntry = entry tssEntry.removeValue(forKey: "Info") @@ -114,7 +119,7 @@ public enum ImagePersonalization { /// Applies `RestoreRequestRules`: when every condition matches the production parameters, the /// rule's actions are written into the entry (255 means “leave unchanged”). - static func applyRules(_ rules: [PlistValue], to entry: [String: PlistValue]) -> [String: PlistValue] { + public static func applyRules(_ rules: [PlistValue], to entry: [String: PlistValue]) -> [String: PlistValue] { var entry = entry for rule in rules { let conditions = rule["Conditions"]?.dictionaryValue ?? [:] @@ -152,6 +157,12 @@ public enum ImagePersonalization { return ticket } + /// Apple's `STATUS` and `MESSAGE` from a signing reply (`STATUS=0&MESSAGE=SUCCESS&…`). + public static func status(fromResponse body: Data) -> (status: Int?, message: String) { + let text = String(decoding: body, as: UTF8.self) + return (field("STATUS", in: text).flatMap { Int($0) }, field("MESSAGE", in: text) ?? "") + } + static func field(_ name: String, in text: String) -> String? { for pair in text.split(separator: "&", maxSplits: 3) { let parts = pair.split(separator: "=", maxSplits: 1) diff --git a/Sources/DeviceKit/Lockdown/DeviceSession.swift b/Sources/DeviceKit/Lockdown/DeviceSession.swift index 58c5741..cc6bc09 100644 --- a/Sources/DeviceKit/Lockdown/DeviceSession.swift +++ b/Sources/DeviceKit/Lockdown/DeviceSession.swift @@ -107,6 +107,12 @@ public actor DeviceSession { try await lockdown.getValue(domain: domain, key: key) } + /// Asks the device to restart into recovery mode (lockdown `EnterRecovery`). The device leaves + /// recovery with a restore, an update, or a "reboot to normal mode" from the recovery tools. + public func enterRecovery() async throws { + _ = try await lockdown.request("EnterRecovery") + } + /// Developer Mode status from AMFI (iOS 16+). `nil` when the device does not report it. public func developerModeEnabled() async throws -> Bool? { try await lockdown.getValue(domain: "com.apple.security.mac.amfi", key: "DeveloperModeStatus")?.boolValue diff --git a/Sources/ToolkitFeatures/Firmware/FirmwareCatalog.swift b/Sources/ToolkitFeatures/Firmware/FirmwareCatalog.swift new file mode 100644 index 0000000..e7805e9 --- /dev/null +++ b/Sources/ToolkitFeatures/Firmware/FirmwareCatalog.swift @@ -0,0 +1,101 @@ +import Foundation +import ToolkitCore + +/// A firmware (IPSW) Apple offers for a device model. +public struct FirmwareRelease: Sendable, Hashable, Identifiable, Codable { + public var id: String { "\(productType)-\(build)" } + public var productType: String + public var version: String + public var build: String + public var url: URL + /// Apple's published SHA-1 of the IPSW. + public var sha1: String? + + public init(productType: String, version: String, build: String, url: URL, sha1: String?) { + self.productType = productType + self.version = version + self.build = build + self.url = url + self.sha1 = sha1 + } + + /// The IPSW's file name (`iPhone18,1_27.0.1_24A446_Restore.ipsw`). + public var fileName: String { url.lastPathComponent } +} + +/// Apple's firmware catalog: the version list Finder and idevicerestore use +/// (`https://itunes.apple.com/check/version`). It lists the firmware Apple currently offers for +/// each model; older firmware is not listed. +public enum FirmwareCatalog { + public static let url = URL(string: "https://itunes.apple.com/check/version")! + /// The catalog is refreshed after a day, like idevicerestore does. + public static let maximumAge: TimeInterval = 86_400 + /// The catalog is a few megabytes; anything far larger is not the catalog. + static let maximumBytes = 64 * 1024 * 1024 + + /// Firmware for `productType`, newest first. + public static func releases(fromCatalog data: Data, productType: String) throws -> [FirmwareRelease] { + guard data.count <= maximumBytes, + let root = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let groups = root["MobileDeviceSoftwareVersionsByVersion"] as? [String: Any] else { + throw ToolkitError(.protocolViolation, message: "Apple's firmware list could not be read.", recovery: "Try again later.") + } + var byBuild: [String: FirmwareRelease] = [:] + for case let group as [String: Any] in groups.values { + guard let models = group["MobileDeviceSoftwareVersions"] as? [String: Any], + let builds = models[productType] as? [String: Any] else { continue } + for case let entry as [String: Any] in builds.values { + guard let restore = entry["Restore"] as? [String: Any], + let version = restore["ProductVersion"] as? String, + let build = restore["BuildVersion"] as? String, + let link = restore["FirmwareURL"] as? String, + var components = URLComponents(string: link) else { continue } + // Apple's CDN serves the same files over HTTPS. + if components.scheme == "http" { components.scheme = "https" } + guard let url = components.url, url.pathExtension == "ipsw" else { continue } + byBuild[build] = FirmwareRelease(productType: productType, version: version, build: build, url: url, sha1: (restore["FirmwareSHA1"] as? String)?.lowercased()) + } + } + return byBuild.values.sorted { $0.version.compare($1.version, options: .numeric) == .orderedDescending } + } + + /// The catalog, from `cache` when it is fresh, otherwise downloaded (and cached). + public static func load(cache: URL, fetcher: DataFetching = URLSessionDataFetcher(), now: Date = Date()) async throws -> Data { + if let attributes = try? FileManager.default.attributesOfItem(atPath: cache.path), + let modified = attributes[.modificationDate] as? Date, now.timeIntervalSince(modified) < maximumAge, + let data = try? Data(contentsOf: cache) { + return data + } + let data = try await fetcher.data(from: url, limit: maximumBytes) + _ = try releases(fromCatalog: data, productType: "") // must at least parse + try SecureFileIO.createPrivateDirectory(at: cache.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: cache) + try SecureFileIO.writeNewFile(data, to: cache) + return data + } +} + +/// Downloads small documents (the catalog). Injected so tests never touch the network. +public protocol DataFetching: Sendable { + func data(from url: URL, limit: Int) async throws -> Data +} + +public struct URLSessionDataFetcher: DataFetching { + public init() {} + + public func data(from url: URL, limit: Int) async throws -> Data { + var request = URLRequest(url: url, timeoutInterval: 60) + request.setValue("iOS Developer Toolkit", forHTTPHeaderField: "User-Agent") + let (data, response): (Data, URLResponse) + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw ToolkitError(.serviceUnavailable, message: "This Mac could not reach Apple's servers.", recovery: "Check the internet connection and try again.", technicalDetail: error.localizedDescription) + } + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw ToolkitError(.serviceUnavailable, message: "Apple's server did not answer as expected.", recovery: "Try again later.", technicalDetail: "HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0) for \(url.absoluteString)") + } + guard data.count <= limit else { throw ToolkitError(.protocolViolation, message: "Apple's server sent more data than expected.") } + return data + } +} diff --git a/Sources/ToolkitFeatures/Firmware/FirmwareManifest.swift b/Sources/ToolkitFeatures/Firmware/FirmwareManifest.swift new file mode 100644 index 0000000..8ab7916 --- /dev/null +++ b/Sources/ToolkitFeatures/Firmware/FirmwareManifest.swift @@ -0,0 +1,164 @@ +import DeviceKit +import Foundation +import ToolkitCore + +/// The parts of an IPSW's `BuildManifest.plist` the toolkit uses. +public struct FirmwareManifest: Sendable, Hashable { + public struct Identity: Sendable, Hashable { + public var chipID: Int + public var boardID: Int + public var deviceClass: String? + public var variant: String? + /// "Erase" for a restore, "Update" for an update that keeps data. + public var restoreBehavior: String? + public var uniqueBuildID: Data? + public var securityDomain: Int + public var requiresUIDMode: Bool + /// The identity's other top-level values (`Ap,OSLongVersion`, `Ap,ProductType`, …). + public var values: [String: PlistValue] + public var manifest: [String: PlistValue] + } + + public var productVersion: String + public var productBuild: String + public var supportedProductTypes: [String] + public var identities: [Identity] + + public static let fileName = "BuildManifest.plist" + public static let maximumBytes: UInt64 = 32 * 1024 * 1024 + + public static func parse(_ data: Data) throws -> FirmwareManifest { + let plist: PlistValue + do { plist = try PlistValue.decode(data) } catch { + throw ToolkitError.invalidInput("The firmware's build manifest could not be read.") + } + guard let version = plist["ProductVersion"]?.stringValue, let build = plist["ProductBuildVersion"]?.stringValue else { + throw ToolkitError.invalidInput("The file is not an iPhone or iPad firmware (its build manifest has no version).") + } + let identities: [Identity] = (plist["BuildIdentities"]?.arrayValue ?? []).compactMap { item in + guard let chip = hexOrInt(item["ApChipID"]), let board = hexOrInt(item["ApBoardID"]), + let manifest = item["Manifest"]?.dictionaryValue else { return nil } + let info = item["Info"] + let values = (item.dictionaryValue ?? [:]).filter { $0.key != "Manifest" && $0.key != "Info" } + return Identity(chipID: chip, boardID: board, deviceClass: info?["DeviceClass"]?.stringValue, variant: info?["Variant"]?.stringValue, + restoreBehavior: info?["RestoreBehavior"]?.stringValue, uniqueBuildID: item["UniqueBuildID"]?.dataValue, + securityDomain: hexOrInt(item["ApSecurityDomain"]) ?? 1, requiresUIDMode: info?["RequiresUIDMode"]?.boolValue ?? false, + values: values, manifest: manifest) + } + return FirmwareManifest(productVersion: version, productBuild: build, + supportedProductTypes: (plist["SupportedProductTypes"]?.arrayValue ?? []).compactMap(\.stringValue), + identities: identities) + } + + static func hexOrInt(_ value: PlistValue?) -> Int? { + if let number = value?.intValue { return number } + guard let text = value?.stringValue?.lowercased() else { return nil } + return text.hasPrefix("0x") ? Int(text.dropFirst(2), radix: 16) : Int(text) + } + + /// The identity to use: the device's chip and board when known, preferring an erase install + /// (the one Apple signs whenever the firmware is signed). + public func identity(chipID: Int? = nil, boardID: Int? = nil, deviceClass: String? = nil, behavior: String = "Erase") -> Identity? { + let matching = identities.filter { + (chipID == nil || $0.chipID == chipID) && (boardID == nil || $0.boardID == boardID) + && (deviceClass == nil || $0.deviceClass?.lowercased() == deviceClass?.lowercased()) + } + return matching.first { $0.restoreBehavior == behavior } ?? matching.first + } +} + +/// Whether Apple still signs a firmware, asked the way Finder and idevicerestore do: a signing +/// request (TSS) to Apple for one of the firmware's build identities. The request uses a random +/// device ID and nonce, so no device information is sent. +public enum FirmwareSigning { + public enum Status: Sendable, Hashable { + case signed + case notSigned + case unknown(String) + + public var label: String { + switch self { + case .signed: return "Signed" + case .notSigned: return "Not signed" + case .unknown: return "Unknown" + } + } + + public var explanation: String { + switch self { + case .signed: return "Apple signs this firmware now, so it can be installed." + case .notSigned: return "Apple no longer signs this firmware, so it cannot be installed." + case .unknown(let reason): return reason + } + } + } + + /// Components that belong in other requests (baseband, SE, recovery OS) or are not signed. + static let skippedComponents: Set = ["BasebandFirmware", "SE,UpdatePayload", "BaseSystem", "Diags", "Ap,ExclaveOS"] + /// Identity values copied into the request when present. + static let copiedValues = ["Ap,OSLongVersion", "Ap,OSReleaseType", "Ap,ProductMarketingVersion", "Ap,ProductType", "Ap,SDKPlatform", + "Ap,Target", "Ap,TargetType", "Ap,Timestamp", "UniqueBuildID", "PearlCertificationRootPub", "NeRDEpoch", + "AllowNeRDBoot", "PermitNeRDPivot"] + + /// The application-processor signing request, built the way libtatsu builds it for a restore. + public static func request(identity: FirmwareManifest.Identity, ecid: UInt64 = UInt64.random(in: 1...UInt64(1) << 52), nonce: Data = randomBytes(32), sepNonce: Data = randomBytes(20), requestID: UUID = UUID()) -> PlistValue { + var request: [String: PlistValue] = [ + "@HostPlatformInfo": "mac", + "@VersionInfo": .string(ImagePersonalization.clientVersion), + "@UUID": .string(requestID.uuidString.uppercased()), + "@ApImg4Ticket": true, + "ApBoardID": .integer(Int64(identity.boardID)), + "ApChipID": .integer(Int64(identity.chipID)), + "ApECID": .integer(Int64(bitPattern: ecid)), + "ApNonce": .data(nonce), + "ApProductionMode": true, + "ApSecurityDomain": .integer(Int64(identity.securityDomain)), + "ApSecurityMode": true, + "SepNonce": .data(sepNonce), + "UID_MODE": false, + ] + if identity.requiresUIDMode { request["Ap,SikaFuse"] = 0 } + for key in copiedValues { if let value = identity.values[key] { request[key] = value } } + for (key, item) in identity.manifest where !skippedComponents.contains(key) { + guard let entry = item.dictionaryValue, let info = entry["Info"] else { continue } + let trusted = entry["Trusted"]?.boolValue == true + let rules = info["RestoreRequestRules"]?.arrayValue + if rules == nil && !trusted { continue } + if info["IsFTAB"]?.boolValue == true { continue } + var tssEntry = entry + tssEntry.removeValue(forKey: "Info") + if let rules { + tssEntry = ImagePersonalization.applyRules(rules, to: tssEntry) + } else { + tssEntry["EPRO"] = true + tssEntry["ESEC"] = true + } + if trusted && tssEntry["Digest"] == nil { tssEntry["Digest"] = .data(Data()) } + if !tssEntry.isEmpty { request[key] = .dictionary(tssEntry) } + } + return .dictionary(request) + } + + public static func status(fromResponse body: Data) -> Status { + let reply = ImagePersonalization.status(fromResponse: body) + switch reply.status { + case 0 where reply.message.uppercased() == "SUCCESS": return .signed + case 94: return .notSigned + default: return .unknown("Apple's signing server gave an unexpected answer (status \(reply.status.map(String.init) ?? "none"): \(reply.message)).") + } + } + + public static func check(identity: FirmwareManifest.Identity, transport: PersonalizationTransport = AppleTSSTransport()) async -> Status { + do { + let body = try request(identity: identity).encoded(format: .xml) + return status(fromResponse: try await transport.send(body)) + } catch { + return .unknown((error as? ToolkitError)?.message ?? error.localizedDescription) + } + } + + public static func randomBytes(_ count: Int) -> Data { + var generator = SystemRandomNumberGenerator() + return Data((0.. UInt64 + func data(_ url: URL, range: ClosedRange) async throws -> Data +} + +public struct HTTPRangeFetcher: RangeFetching { + public init() {} + + public func size(of url: URL) async throws -> UInt64 { + var request = URLRequest(url: url, timeoutInterval: 60) + request.httpMethod = "HEAD" + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, http.expectedContentLength > 0 else { + throw ToolkitError(.serviceUnavailable, message: "Apple's download server did not answer.", recovery: "Check the internet connection and try again.") + } + return UInt64(http.expectedContentLength) + } + + public func data(_ url: URL, range: ClosedRange) async throws -> Data { + var request = URLRequest(url: url, timeoutInterval: 120) + request.setValue("bytes=\(range.lowerBound)-\(range.upperBound)", forHTTPHeaderField: "Range") + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 206, UInt64(data.count) == range.upperBound - range.lowerBound + 1 else { + throw ToolkitError(.serviceUnavailable, message: "Apple's download server did not return the requested part of the firmware.", recovery: "Try again later.") + } + return data + } +} + +/// Reads one small file from a ZIP on a web server without downloading the whole archive (an +/// IPSW is several gigabytes; its build manifest is a few hundred kilobytes). +public enum RemoteArchive { + public static func file(named name: String, in url: URL, fetcher: RangeFetching = HTTPRangeFetcher(), limit: UInt64 = FirmwareManifest.maximumBytes) async throws -> Data { + let size = try await fetcher.size(of: url) + guard size >= 22 else { throw ToolkitError.invalidInput("The firmware file is too small to be an IPSW.") } + let tailLength = min(size, 65_557) + var location = try ZipArchive.directoryLocation(tail: try await fetcher.data(url, range: (size - tailLength)...(size - 1))) + if let recordOffset = location.zip64RecordOffset { + try ZipArchive.applyZip64Record(try await fetcher.data(url, range: recordOffset...(recordOffset + 55)), to: &location) + } + try ZipArchive.checkLocation(location, fileSize: size) + let directory = try await fetcher.data(url, range: location.offset...(location.offset + location.size - 1)) + guard let entry = try ZipArchive.entries(directory: directory, count: location.entryCount).first(where: { $0.name == name }) else { + throw ToolkitError.invalidInput("The firmware has no \(name).") + } + guard entry.uncompressedSize <= limit, entry.compressedSize <= limit else { + throw ToolkitError.invalidInput("\(name) is larger than expected.") + } + // The local header is 30 bytes plus the name and an extra field (read generously). + let header = try await fetcher.data(url, range: entry.localHeaderOffset...min(size - 1, entry.localHeaderOffset + 30 + 1024 + 65_535)) + let start = try ZipArchive.dataOffset(localHeader: header, for: entry) + guard entry.compressedSize > 0 else { return Data() } + let compressed = try await fetcher.data(url, range: start...(start + entry.compressedSize - 1)) + return try ZipArchive.decompress(compressed, entry: entry) + } +} + +// MARK: - The local IPSW library + +/// An IPSW on this Mac. +public struct IPSWFile: Sendable, Hashable, Identifiable { + public var id: String { url.path } + public var url: URL + public var size: UInt64 + public var manifest: FirmwareManifest + + public var title: String { "iOS \(manifest.productVersion) (\(manifest.productBuild))" } + + public func supports(productType: String) -> Bool { manifest.supportedProductTypes.contains(productType) } +} + +public enum IPSWLibrary { + /// IPSW archives hold several gigabytes of system images. + public static let maximumTotal: UInt64 = 64 * 1024 * 1024 * 1024 + + public static func defaultDirectory(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { + home.appendingPathComponent("Library/Application Support/\(ToolkitVersion.applicationName)/Firmware") + } + + /// Reads an IPSW's build manifest. + public static func inspect(_ url: URL) throws -> IPSWFile { + let archive = try ZipArchive(url: url, maximumTotal: maximumTotal) + guard let entry = archive.entry(named: FirmwareManifest.fileName) else { + throw ToolkitError.invalidInput("\(url.lastPathComponent) is not an iPhone or iPad firmware (it has no build manifest).") + } + let manifest = try FirmwareManifest.parse(try archive.data(for: entry, limit: FirmwareManifest.maximumBytes)) + let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.uint64Value ?? 0 + return IPSWFile(url: url, size: size, manifest: manifest) + } + + /// Every readable IPSW in `directory`, newest first. Unreadable files are skipped. + public static func scan(_ directory: URL) -> [IPSWFile] { + let urls = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] + return urls.filter { $0.pathExtension.lowercased() == "ipsw" } + .compactMap { try? inspect($0) } + .sorted { $0.manifest.productVersion.compare($1.manifest.productVersion, options: .numeric) == .orderedDescending } + } + + /// SHA-1 (Apple's published checksum) and SHA-256 of a file, read in chunks. + public static func checksums(of url: URL, progress: (Double) -> Void = { _ in }) throws -> (sha1: String, sha256: String) { + guard let handle = try? FileHandle(forReadingFrom: url) else { throw ToolkitError.fileSystem("The firmware could not be opened.", path: url.path) } + defer { try? handle.close() } + let total = max(1, (try? handle.seekToEnd()) ?? 1) + try handle.seek(toOffset: 0) + var sha1 = Insecure.SHA1(), sha256 = SHA256() + var done: UInt64 = 0 + while let chunk = try handle.read(upToCount: 8 << 20), !chunk.isEmpty { + try Task.checkCancellation() + sha1.update(data: chunk); sha256.update(data: chunk) + done += UInt64(chunk.count) + progress(Double(done) / Double(total)) + } + return (sha1.finalize().map { String(format: "%02x", $0) }.joined(), sha256.finalize().map { String(format: "%02x", $0) }.joined()) + } +} + +// MARK: - Downloading + +/// Downloads an IPSW into the library, resuming a previous attempt when possible, and keeps it +/// only when its SHA-1 matches Apple's. +public final class FirmwareDownloader: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var progressHandler: (@Sendable (Int64, Int64) -> Void)? + private var task: URLSessionDownloadTask? + private var staging: URL? + + public static func resumeFile(for destination: URL) -> URL { destination.appendingPathExtension("resume") } + + /// Downloads `release` to `directory`/`release.fileName` and verifies it. + public func download(_ release: FirmwareRelease, to directory: URL, progress: @escaping @Sendable (_ written: Int64, _ total: Int64) -> Void) async throws -> URL { + try SecureFileIO.createPrivateDirectory(at: directory) + let destination = directory.appendingPathComponent(release.fileName) + guard !FileManager.default.fileExists(atPath: destination.path) else { + throw ToolkitError.invalidInput("\(release.fileName) is already in the library.") + } + let resumeFile = Self.resumeFile(for: destination) + let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + defer { session.finishTasksAndInvalidate() } + let downloaded: URL = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + lock.withLock { + self.continuation = continuation + self.progressHandler = progress + self.staging = directory.appendingPathComponent(".\(release.fileName).part") + if let data = try? Data(contentsOf: resumeFile) { + task = session.downloadTask(withResumeData: data) + } else { + task = session.downloadTask(with: release.url) + } + task?.resume() + } + } + } onCancel: { + lock.withLock { task }?.cancel(byProducingResumeData: { data in + if let data { try? data.write(to: resumeFile, options: .atomic) } + }) + } + try? FileManager.default.removeItem(at: resumeFile) + if let expected = release.sha1 { + let actual = try IPSWLibrary.checksums(of: downloaded).sha1 + guard actual == expected else { + try? FileManager.default.removeItem(at: downloaded) + throw ToolkitError(.fileSystem, message: "The downloaded firmware does not match Apple's checksum, so it was deleted.", recovery: "Download it again.", technicalDetail: "SHA-1 expected \(expected), got \(actual)") + } + } + try FileManager.default.moveItem(at: downloaded, to: destination) + return destination + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + lock.withLock { progressHandler }?(totalBytesWritten, totalBytesExpectedToWrite) + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + let (staging, continuation) = lock.withLock { () -> (URL?, CheckedContinuation?) in + defer { self.continuation = nil } + return (self.staging, self.continuation) + } + guard let staging, let continuation else { return } + let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0 + guard status == 200 || status == 206 else { + continuation.resume(throwing: ToolkitError(.serviceUnavailable, message: "Apple's download server did not send the firmware.", technicalDetail: "HTTP \(status)")) + return + } + do { + try? FileManager.default.removeItem(at: staging) + try FileManager.default.moveItem(at: location, to: staging) + continuation.resume(returning: staging) + } catch { + continuation.resume(throwing: error) + } + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let error else { return } + let continuation = lock.withLock { () -> CheckedContinuation? in + defer { self.continuation = nil } + return self.continuation + } + if (error as? URLError)?.code == .cancelled { + continuation?.resume(throwing: CancellationError()) + } else { + continuation?.resume(throwing: ToolkitError(.serviceUnavailable, message: "The firmware download stopped.", recovery: "Check the internet connection and download again; it continues where it stopped.", technicalDetail: error.localizedDescription)) + } + } +} diff --git a/Sources/ToolkitFeatures/Firmware/RestoreHelpers.swift b/Sources/ToolkitFeatures/Firmware/RestoreHelpers.swift new file mode 100644 index 0000000..3557643 --- /dev/null +++ b/Sources/ToolkitFeatures/Firmware/RestoreHelpers.swift @@ -0,0 +1,254 @@ +import Foundation +import ToolkitCore + +/// The firmware helpers bundled with the app: `idevicerestore` and `irecovery` from the +/// libimobiledevice project, built by `scripts/build-restore-helpers.sh` and shipped in +/// `Contents/Helpers`. They are separate programs, run only through `CommandRunner` with argument +/// vectors (idevicerestore is LGPL-3.0; its libraries LGPL-2.1). +public enum RestoreHelper: String, CaseIterable, Sendable { + case idevicerestore + case irecovery + + /// Where the helper is: the app's `Contents/Helpers`, or `IDT_RESTORE_HELPERS` (a folder, for + /// development and tests). + public func locate(bundle: Bundle = .main, environment: [String: String] = ProcessInfo.processInfo.environment) throws -> URL { + var candidates: [URL] = [] + if let folder = environment["IDT_RESTORE_HELPERS"], !folder.isEmpty { + candidates.append(URL(fileURLWithPath: folder).appendingPathComponent(rawValue)) + } + candidates.append(bundle.bundleURL.appendingPathComponent("Contents/Helpers/\(rawValue)")) + for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate.path) { + return candidate + } + throw ToolkitError(.toolMissing, message: "The firmware tools are not included in this build.", recovery: "Use a release build of the app, or run scripts/build-restore-helpers.sh and set IDT_RESTORE_HELPERS to its bin folder.", technicalDetail: "Checked: " + candidates.map(\.path).joined(separator: ", ")) + } +} + +// MARK: - Recovery and DFU mode + +/// A device in recovery or DFU mode, as `irecovery -q` reports it. +public struct RecoveryDevice: Sendable, Hashable { + public enum Mode: Sendable, Hashable { + case recovery + case dfu + case other(String) + + public var label: String { + switch self { + case .recovery: return "Recovery mode" + case .dfu: return "DFU mode" + case .other(let text): return text + } + } + } + + public var mode: Mode + public var ecid: String + public var chipID: String? + public var boardID: String? + public var productType: String? + public var model: String? +} + +public enum RecoveryProbe { + public static func queryRequest(helper: URL) -> CommandRequest { + CommandRequest(executable: helper, arguments: ["-q"], timeout: 20, displayName: "irecovery -q") + } + + /// Asks a device in recovery mode to restart normally (`irecovery -n`). + public static func exitRecoveryRequest(helper: URL, ecid: String) throws -> CommandRequest { + guard ecid.range(of: #"^(0x)?[0-9A-Fa-f]{1,16}$"#, options: .regularExpression) != nil else { + throw ToolkitError.invalidInput("That is not a device ECID.") + } + return CommandRequest(executable: helper, arguments: ["-i", ecid, "-n"], timeout: 60, displayName: "irecovery -n") + } + + /// Parses `irecovery -q` (`KEY: value` lines). `nil` when no device answered. + public static func parse(_ output: String) -> RecoveryDevice? { + var fields: [String: String] = [:] + for line in output.split(whereSeparator: \.isNewline) { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = line[.. CommandRequest { + guard ipsw.pathExtension.lowercased() == "ipsw", FileManager.default.fileExists(atPath: ipsw.path) else { + throw ToolkitError.invalidInput("Choose an .ipsw file.") + } + var arguments = ["--plain-progress", "--no-input", "--cache-path", cacheDirectory.path, "--logfile", logFile.path] + switch target { + case .udid(let udid): + guard udid.range(of: #"^[0-9A-Fa-f-]{24,40}$"#, options: .regularExpression) != nil else { throw ToolkitError.invalidInput("That is not a device UDID.") } + arguments += ["--udid", udid] + case .ecid(let ecid): + guard ecid.range(of: #"^(0x)?[0-9A-Fa-f]{1,16}$"#, options: .regularExpression) != nil else { throw ToolkitError.invalidInput("That is not a device ECID.") } + arguments += ["--ecid", ecid] + } + if mode == .restore { arguments.append("--erase") } + if preflightOnly { arguments.append("--no-action") } + arguments.append(ipsw.path) + return CommandRequest(executable: helper, arguments: arguments, environment: CommandEnvironment.minimal(), timeout: nil, displayName: preflightOnly ? "idevicerestore (preflight)" : "idevicerestore (\(mode.rawValue))") + } + + /// The stages idevicerestore reports (`RESTORE_STEP_*`). + public static let steps = [ + "Finding the device", "Preparing", "Sending the system", "Verifying the system", + "Installing firmware", "Installing baseband firmware", "Updating accessories firmware", "Sending images", + ] + + /// Parses a `--plain-progress` line: `progress: `. + public static func progress(_ line: Substring) -> (step: String, fraction: Double)? { + let parts = line.split(separator: " ") + guard parts.count == 3, parts[0] == "progress:", let step = Int(parts[1]), let fraction = Double(parts[2]) else { return nil } + let name = steps.indices.contains(step) ? steps[step] : "Step \(step)" + return (name, min(max(fraction, 0), 1)) + } + + /// Whether stopping now could leave the device unusable until it is restored again. + public static func isPastPointOfNoReturn(step: String) -> Bool { + guard let index = steps.firstIndex(of: step) else { return false } + return index >= 2 + } + + /// A plain-language reason for a failed run, from idevicerestore's output. + public static func failureReason(output: String) -> String { + let lower = output.lowercased() + if lower.contains("isn't eligible") || lower.contains("not eligible") || lower.contains("status 94") { + return "Apple does not sign this firmware for this device any more, so it cannot be installed." + } + if lower.contains("unable to find device") || lower.contains("no device found") || lower.contains("unable to discover device") { + return "The device could not be found. Keep it connected by USB and unlocked (or in recovery mode) and try again." + } + if lower.contains("product type") && lower.contains("not") { + return "This firmware is not for this device model." + } + if let line = output.split(whereSeparator: \.isNewline).last(where: { $0.hasPrefix("ERROR:") }) { + return String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces) + } + return "The firmware tool stopped with an error." + } +} + +extension RecoveryProbe { + /// The device in recovery or DFU mode, if one is connected. Never changes anything. + public static func query(runner: CommandRunning, helper: URL) async -> RecoveryDevice? { + guard let result = try? await runner.run(queryRequest(helper: helper)), result.succeeded else { return nil } + return parse(result.standardOutputText) + } +} + +extension FirmwareInstall { + /// Runs idevicerestore, reporting each progress step and every other output line. Throws a + /// plain-language error when it fails; stopping the task stops the helper. + public static func run(_ request: CommandRequest, runner: CommandRunning, + progress: @escaping @Sendable (_ step: String, _ fraction: Double) -> Void, + line: @escaping @Sendable (String) -> Void) async throws -> CommandResult { + var output = LineSplitter(), errors = LineSplitter() + var transcript: [String] = [] + func handle(_ lines: [Substring]) { + for text in lines { + if let update = Self.progress(text) { + progress(update.step, update.fraction) + } else if !text.trimmingCharacters(in: .whitespaces).isEmpty { + transcript.append(String(text)) + if transcript.count > 2_000 { transcript.removeFirst(500) } + line(String(text)) + } + } + } + var final: CommandResult? + for try await event in runner.stream(request) { + switch event { + case .standardOutput(let data): handle(output.consume(data)) + case .standardError(let data): handle(errors.consume(data)) + case .finished(let result): final = result + } + } + handle(output.flush()) + handle(errors.flush()) + try Task.checkCancellation() + guard let final else { throw ToolkitError(.internalInconsistency, message: "\(request.displayName) ended without a result.") } + guard final.succeeded else { + throw ToolkitError(.commandFailed, message: failureReason(output: transcript.joined(separator: "\n")), + recovery: "The full log is in the firmware folder. If the device is stuck in recovery mode, install the firmware again with Restore.", + technicalDetail: final.technicalSummary) + } + return final + } +} + +/// The checks made before installing, so a firmware that cannot be installed is caught before the +/// device is touched. +public enum FirmwarePreflight { + public struct Check: Sendable, Hashable, Identifiable { + public var id: String { title } + public var title: String + /// `nil` while the check has not run. + public var passed: Bool? + public var detail: String + + public init(title: String, passed: Bool?, detail: String) { + self.title = title + self.passed = passed + self.detail = detail + } + } + + /// The checks that need only the IPSW and the device's model. + public static func localChecks(ipsw: IPSWFile, productType: String?, deviceClass: String?, mode: FirmwareInstall.Mode) -> [Check] { + var checks: [Check] = [] + if let productType { + let supported = ipsw.supports(productType: productType) + checks.append(Check(title: "Made for this model", passed: supported, + detail: supported ? "\(ipsw.title) supports \(productType)." : "\(ipsw.title) is for \(ipsw.manifest.supportedProductTypes.joined(separator: ", ")), not \(productType).")) + } + let behavior = mode == .update ? "Update" : "Erase" + let identity = ipsw.manifest.identities.first { ($0.restoreBehavior == behavior) && (deviceClass == nil || $0.deviceClass?.lowercased() == deviceClass?.lowercased()) } + checks.append(Check(title: mode == .update ? "Can update in place" : "Can restore", + passed: identity != nil, + detail: identity != nil ? "The firmware has a \(behavior.lowercased()) install for this device." : "The firmware has no \(behavior.lowercased()) install for this device\(mode == .update ? "; use Restore instead" : "").")) + return checks + } + + public static func signingCheck(_ status: FirmwareSigning.Status) -> Check { + Check(title: "Signed by Apple", passed: status == .signed ? true : (status == .notSigned ? false : nil), detail: status.explanation) + } +} diff --git a/Sources/ToolkitFeatures/IPA/ZipArchive.swift b/Sources/ToolkitFeatures/IPA/ZipArchive.swift index 47bbfc0..743043e 100644 --- a/Sources/ToolkitFeatures/IPA/ZipArchive.swift +++ b/Sources/ToolkitFeatures/IPA/ZipArchive.swift @@ -32,7 +32,9 @@ public final class ZipArchive: @unchecked Sendable { private let handle: FileHandle private let fileSize: UInt64 - public init(url: URL) throws { + /// Opens a ZIP file. `maximumTotal` bounds the declared uncompressed size of all entries + /// together (firmware archives are larger than app packages). + public init(url: URL, maximumTotal: UInt64 = ZipArchive.maximumTotalUncompressedBytes) throws { guard let handle = try? FileHandle(forReadingFrom: url) else { throw ToolkitError.fileSystem("The package could not be opened.", path: url.path) } @@ -40,7 +42,7 @@ public final class ZipArchive: @unchecked Sendable { self.handle = handle fileSize = (try? handle.seekToEnd()) ?? 0 entries = try ZipArchive.readCentralDirectory(handle: handle, fileSize: fileSize) - try ZipArchive.validate(entries) + try ZipArchive.validate(entries, maximumTotal: maximumTotal) } deinit { @@ -49,7 +51,7 @@ public final class ZipArchive: @unchecked Sendable { // MARK: Validation - public static func validate(_ entries: [Entry]) throws { + public static func validate(_ entries: [Entry], maximumTotal: UInt64 = maximumTotalUncompressedBytes) throws { var seen = Set() var total: UInt64 = 0 for entry in entries { @@ -67,7 +69,7 @@ public final class ZipArchive: @unchecked Sendable { throw ToolkitError.invalidInput("The package uses an unsupported compression method (\(entry.compressionMethod)).") } total += entry.uncompressedSize - if total > maximumTotalUncompressedBytes { + if total > maximumTotal { throw ToolkitError.invalidInput("The package expands beyond the 8 GB inspection limit.") } } @@ -207,38 +209,97 @@ public final class ZipArchive: @unchecked Sendable { // MARK: Central directory - static func readCentralDirectory(handle: FileHandle, fileSize: UInt64) throws -> [Entry] { - guard fileSize >= 22 else { throw ToolkitError.invalidInput("The file is not a valid package (too small).") } - let tailLength = min(fileSize, 65_557) - try handle.seek(toOffset: fileSize - tailLength) - let tail = try handle.read(upToCount: Int(tailLength)) ?? Data() + /// Where the central directory lives, from the last bytes of the archive. + public struct DirectoryLocation: Sendable, Equatable { + public var entryCount: UInt64 + public var size: UInt64 + public var offset: UInt64 + /// Set when the archive uses ZIP64: the offset of the 56-byte ZIP64 end record to read next. + public var zip64RecordOffset: UInt64? + } + + /// Parses the end-of-central-directory record from the archive's last (up to 65,557) bytes. + public static func directoryLocation(tail: Data) throws -> DirectoryLocation { guard let eocd = tail.lastRange(of: Data([0x50, 0x4B, 0x05, 0x06]))?.lowerBound else { throw ToolkitError.invalidInput("The file is not a valid ZIP-based package.") } let eocdRelative = eocd - tail.startIndex - var entryCount = UInt64(tail.readUInt16LE(eocdRelative + 10)) - var directorySize = UInt64(tail.readUInt32LE(eocdRelative + 12)) - var directoryOffset = UInt64(tail.readUInt32LE(eocdRelative + 16)) - - if entryCount == 0xFFFF || directorySize == 0xFFFF_FFFF || directoryOffset == 0xFFFF_FFFF { - // ZIP64: locate the ZIP64 end-of-central-directory record. + guard eocdRelative + 22 <= tail.count else { throw ToolkitError.invalidInput("The package directory is damaged.") } + var location = DirectoryLocation( + entryCount: UInt64(tail.readUInt16LE(eocdRelative + 10)), + size: UInt64(tail.readUInt32LE(eocdRelative + 12)), + offset: UInt64(tail.readUInt32LE(eocdRelative + 16)), + zip64RecordOffset: nil + ) + if location.entryCount == 0xFFFF || location.size == 0xFFFF_FFFF || location.offset == 0xFFFF_FFFF { guard eocdRelative >= 20, tail.readUInt32LE(eocdRelative - 20) == 0x0706_4B50 else { throw ToolkitError.invalidInput("The package's ZIP64 directory is missing.") } - let zip64Offset = tail.readUInt64LE(eocdRelative - 12) - try handle.seek(toOffset: zip64Offset) - guard let record = try handle.read(upToCount: 56), record.count == 56, record.readUInt32LE(0) == 0x0606_4B50 else { - throw ToolkitError.invalidInput("The package's ZIP64 directory is damaged.") - } - entryCount = record.readUInt64LE(32) - directorySize = record.readUInt64LE(40) - directoryOffset = record.readUInt64LE(48) + location.zip64RecordOffset = tail.readUInt64LE(eocdRelative - 12) } - guard entryCount <= UInt64(maximumEntries), directoryOffset + directorySize <= fileSize, directorySize <= 512 * 1024 * 1024 else { + return location + } + + /// Completes a ZIP64 location from the 56-byte ZIP64 end-of-central-directory record. + public static func applyZip64Record(_ record: Data, to location: inout DirectoryLocation) throws { + guard record.count >= 56, record.readUInt32LE(0) == 0x0606_4B50 else { + throw ToolkitError.invalidInput("The package's ZIP64 directory is damaged.") + } + location.entryCount = record.readUInt64LE(32) + location.size = record.readUInt64LE(40) + location.offset = record.readUInt64LE(48) + location.zip64RecordOffset = nil + } + + /// Checks a directory location against the archive size before it is read. + public static func checkLocation(_ location: DirectoryLocation, fileSize: UInt64) throws { + guard location.entryCount <= UInt64(maximumEntries), location.offset + location.size <= fileSize, location.size <= 512 * 1024 * 1024 else { throw ToolkitError.invalidInput("The package directory is invalid or too large.") } - try handle.seek(toOffset: directoryOffset) - let directory = try handle.read(upToCount: Int(directorySize)) ?? Data() + } + + /// Where an entry's data starts, from its local header (at least the first 30 bytes). + public static func dataOffset(localHeader: Data, for entry: Entry) throws -> UInt64 { + guard localHeader.count >= 30, localHeader.readUInt32LE(0) == 0x0403_4B50 else { + throw ToolkitError.invalidInput("The package has a damaged entry header: \(entry.name)") + } + return entry.localHeaderOffset + 30 + UInt64(localHeader.readUInt16LE(26)) + UInt64(localHeader.readUInt16LE(28)) + } + + /// Decompresses one entry held in memory (stored or deflated), checking its declared size. + public static func decompress(_ data: Data, entry: Entry) throws -> Data { + if entry.compressionMethod == 0 { + guard UInt64(data.count) == entry.uncompressedSize else { throw ToolkitError.invalidInput("\(entry.name) is truncated or corrupt.") } + return data + } + guard entry.compressionMethod == 8 else { throw ToolkitError.invalidInput("\(entry.name) uses an unsupported compression method.") } + let capacity = Int(entry.uncompressedSize) + var output = Data(count: capacity) + let written = output.withUnsafeMutableBytes { destination in + data.withUnsafeBytes { source in + compression_decode_buffer(destination.bindMemory(to: UInt8.self).baseAddress!, capacity, source.bindMemory(to: UInt8.self).baseAddress!, data.count, nil, COMPRESSION_ZLIB) + } + } + guard written == capacity else { throw ToolkitError.invalidInput("\(entry.name) is truncated or corrupt.") } + return output + } + + static func readCentralDirectory(handle: FileHandle, fileSize: UInt64) throws -> [Entry] { + guard fileSize >= 22 else { throw ToolkitError.invalidInput("The file is not a valid package (too small).") } + let tailLength = min(fileSize, 65_557) + try handle.seek(toOffset: fileSize - tailLength) + var location = try directoryLocation(tail: try handle.read(upToCount: Int(tailLength)) ?? Data()) + if let recordOffset = location.zip64RecordOffset { + try handle.seek(toOffset: recordOffset) + try applyZip64Record(try handle.read(upToCount: 56) ?? Data(), to: &location) + } + try checkLocation(location, fileSize: fileSize) + try handle.seek(toOffset: location.offset) + return try entries(directory: try handle.read(upToCount: Int(location.size)) ?? Data(), count: location.entryCount) + } + + /// Parses `count` central-directory records. + public static func entries(directory: Data, count entryCount: UInt64) throws -> [Entry] { var entries: [Entry] = [] var cursor = 0 for _ in 0.. Bool { switch self { - case .backup, .evidence: return kind == .physical || kind == nil || kind == .demo + case .backup, .evidence, .firmware: return kind == .physical || kind == nil || kind == .demo default: return true } } diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b66782b..6d31608 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -21,6 +21,33 @@ Each release app contains the exact `LICENSE` and `NOTICE` files of these packag `Contents/Resources/Licenses/`, and each release has an SPDX SBOM generated from `Package.resolved` (see [docs/release-verification.md](docs/release-verification.md)). +## Firmware helpers bundled as separate programs + +The Firmware page installs firmware with `idevicerestore` and reads recovery and DFU devices with +`irecovery`, from the [libimobiledevice](https://libimobiledevice.org) project. They are separate +executables in `Contents/Helpers`, run as child processes with argument vectors; the app and `idt` +do not link them. Each helper is statically linked against the libraries below and depends only on +macOS system libraries. [`scripts/build-restore-helpers.sh`](scripts/build-restore-helpers.sh) +builds them from these exact sources: + +| Component | Version | Source | License | +|---|---|---|---| +| [idevicerestore](https://github.com/libimobiledevice/idevicerestore) | 1.0.0-git | commit `60192e97` | LGPL-3.0-or-later | +| [libirecovery](https://github.com/libimobiledevice/libirecovery) | 1.3.1-git | commit `93c117c2` | LGPL-2.1-or-later | +| [libimobiledevice](https://github.com/libimobiledevice/libimobiledevice) | 1.4.0-git | commit `fa0f7919` | LGPL-2.1-or-later | +| [libimobiledevice-glue](https://github.com/libimobiledevice/libimobiledevice-glue) | 1.3.2-git | commit `da770a76` | LGPL-2.1-or-later | +| [libusbmuxd](https://github.com/libimobiledevice/libusbmuxd) | 2.1.1-git | commit `93eb168b` | LGPL-2.1-or-later | +| [libtatsu](https://github.com/libimobiledevice/libtatsu) | 1.0.5-git | commit `e7d6ad13` | LGPL-2.1-or-later | +| [libplist](https://github.com/libimobiledevice/libplist) | 2.7.0-git | commit `32428aba` | LGPL-2.1-or-later | +| [libzip](https://libzip.org) | 1.11.4 | commit `6f8a0cdd` | BSD-3-Clause | +| [OpenSSL](https://www.openssl.org) | 3.5.8 | release tarball, SHA-256 `a8f84a39…64f5b2` | Apache-2.0 | + +Each release app contains these projects' license files and `SOURCES.txt` in +`Contents/Resources/Licenses/restore-helpers/`, and each release attaches +`…-firmware-helpers-source.tar.gz` with their complete source and the build script. You may +replace the helpers with your own build of the same programs: put them in `Contents/Helpers`, or +point `IDT_RESTORE_HELPERS` at a folder that contains them. + ## Data The Location Lab world map is derived from [Natural Earth](https://www.naturalearthdata.com) diff --git a/Tests/ToolkitFeaturesTests/CollectedLogTests.swift b/Tests/ToolkitFeaturesTests/CollectedLogTests.swift index 206cd2a..64d2837 100644 --- a/Tests/ToolkitFeaturesTests/CollectedLogTests.swift +++ b/Tests/ToolkitFeaturesTests/CollectedLogTests.swift @@ -109,6 +109,14 @@ struct CollectedLogTests { for try await chunk in CollectedLogs.stream(.dvt, target: phone, seconds: 30, artifact: trace, runner: runner) { dvtLines += chunk.lines } #expect(FileManager.default.fileExists(atPath: trace.path)) #expect(dvtLines.contains { $0.message == "Launched Maps" && $0.timestamp != nil }) + + // Instruments may report run issues (exit 2) yet keep a usable trace: it is read, with a note. + runner.recordWithRunIssues = true + var withIssues: [LogLine] = [] + for try await chunk in CollectedLogs.stream(.dvt, target: phone, seconds: 30, artifact: folder.appendingPathComponent("e.trace"), runner: runner) { withIssues += chunk.lines } + #expect(withIssues.contains { $0.message == "Launched Maps" }) + #expect(withIssues.contains { $0.level == "note" && $0.message.contains("Run issues were detected") }) + runner.recordWithRunIssues = false } // A refusal from `log collect` surfaces as a plain-language error. @@ -123,6 +131,7 @@ struct CollectedLogTests { final class FakeToolRunner: CommandRunning, @unchecked Sendable { let exportedXML: Data var refuseCollect = false + var recordWithRunIssues = false let requests = LockedValue<[CommandRequest]>([]) init(exportedXML: Data) { self.exportedXML = exportedXML } @@ -138,6 +147,7 @@ final class FakeToolRunner: CommandRunning, @unchecked Sendable { if refuseCollect { code = 1; stderr = "log: Must be run as root" } else if let out = output(request) { try FileManager.default.createDirectory(at: out, withIntermediateDirectories: true) } } else if args.contains("record"), let out = output(request) { try FileManager.default.createDirectory(at: out, withIntermediateDirectories: true) + if recordWithRunIssues { code = 2; stderr = "Run issues were detected (trace is still ready to be viewed):\n* [Error] Target failed to run" } } else if args.contains("--toc"), let out = output(request) { try Data("2027-01-15T08:00:00.000Z".utf8).write(to: out) } else if args.contains("--xpath"), let out = output(request) { diff --git a/Tests/ToolkitFeaturesTests/FirmwareTests.swift b/Tests/ToolkitFeaturesTests/FirmwareTests.swift new file mode 100644 index 0000000..1fa7523 --- /dev/null +++ b/Tests/ToolkitFeaturesTests/FirmwareTests.swift @@ -0,0 +1,300 @@ +import Foundation +import Testing +@testable import DeviceKit +@testable import ToolkitFeatures +import ToolkitCore + +@Suite("Firmware: catalog, manifests, signing, library, helpers") +struct FirmwareTests { + // MARK: Fixtures + + /// Abridged from Apple's catalog (https://itunes.apple.com/check/version). + static let catalog = """ + + + MobileDeviceSoftwareVersionsByVersion + 17MobileDeviceSoftwareVersions + iPhone18,1 + 23A330Restore + BuildVersion24A446ProductVersion27.0.1 + FirmwareURLhttp://updates.cdn-apple.com/2026FallFCS/fullrestores/093-1/iPhone18,1_27.0.1_24A446_Restore.ipsw + FirmwareSHA1D14FB2344A6831E36C410BA59ECD977F78F22235 + 24A446Restore + BuildVersion24A446ProductVersion27.0.1 + FirmwareURLhttp://updates.cdn-apple.com/2026FallFCS/fullrestores/093-1/iPhone18,1_27.0.1_24A446_Restore.ipsw + FirmwareSHA1d14fb2344a6831e36c410ba59ecd977f78f22235 + + iPhone17,122A1Restore + BuildVersion23D8133ProductVersion26.3.1 + FirmwareURLhttps://updates.cdn-apple.com/x/iPhone17,1_26.3.1_23D8133_Restore.ipsw + + 16MobileDeviceSoftwareVersions + iPhone18,123A1Restore + BuildVersion23D8133ProductVersion26.3.1 + FirmwareURLhttps://updates.cdn-apple.com/y/iPhone18,1_26.3.1_23D8133_Restore.ipsw + + + + """ + + static func manifestData() throws -> Data { + func identity(_ behavior: String, board: String) -> PlistValue { + .dictionary([ + "ApChipID": "0x8150", "ApBoardID": .string(board), "ApSecurityDomain": "0x01", + "UniqueBuildID": .data(Data([1, 2, 3, 4])), "Ap,ProductType": "iPhone18,1", "Ap,OSLongVersion": "27.0.1.24A446", + "Info": .dictionary(["DeviceClass": "v53ap", "Variant": .string("Customer \(behavior) Install (IPSW)"), "RestoreBehavior": .string(behavior)]), + "Manifest": .dictionary([ + "KernelCache": .dictionary(["Digest": .data(Data(repeating: 7, count: 48)), "Trusted": true, "Info": .dictionary(["Path": "kernelcache"])]), + "Untrusted": .dictionary(["Digest": .data(Data([9])), "Info": .dictionary(["Path": "x"])]), + "BasebandFirmware": .dictionary(["Digest": .data(Data([8])), "Trusted": true, "Info": .dictionary(["Path": "bb"])]), + "Ap,Rules": .dictionary(["Digest": .data(Data([6])), "Info": .dictionary(["Path": "r", "RestoreRequestRules": .array([ + .dictionary(["Conditions": .dictionary(["ApRawProductionMode": true]), "Actions": .dictionary(["EPRO": true])]), + ])])]), + ]), + ]) + } + return try PlistValue.dictionary([ + "ProductVersion": "27.0.1", "ProductBuildVersion": "24A446", + "SupportedProductTypes": .array(["iPhone18,1"]), + "BuildIdentities": .array([identity("Update", board: "0x0C"), identity("Erase", board: "0x0C"), identity("Erase", board: "0x0E")]), + ]).encoded(format: .xml) + } + + static func fakeIPSW(in directory: URL, name: String = "iPhone18,1_27.0.1_24A446_Restore.ipsw") throws -> URL { + var writer = ZipWriter() + try writer.add(name: "Restore.plist", data: Data("x".utf8)) + try writer.add(name: FirmwareManifest.fileName, data: try manifestData()) + try writer.add(name: "Firmware/all_flash/iBoot.im4p", data: Data(repeating: 1, count: 4096)) + let url = directory.appendingPathComponent(name) + try SecureFileIO.writeNewFile(writer.finalized(), to: url) + return url + } + + // MARK: Catalog + + @Test func readsApplesFirmwareCatalog() throws { + let releases = try FirmwareCatalog.releases(fromCatalog: Data(Self.catalog.utf8), productType: "iPhone18,1") + #expect(releases.map(\.build) == ["24A446", "23D8133"], "deduplicated and newest first") + #expect(releases[0].version == "27.0.1" && releases[0].url.scheme == "https", "HTTP links are upgraded to HTTPS") + #expect(releases[0].sha1 == "d14fb2344a6831e36c410ba59ecd977f78f22235") + #expect(releases[0].fileName == "iPhone18,1_27.0.1_24A446_Restore.ipsw") + #expect(try FirmwareCatalog.releases(fromCatalog: Data(Self.catalog.utf8), productType: "iPad99,9").isEmpty) + #expect(throws: ToolkitError.self) { try FirmwareCatalog.releases(fromCatalog: Data("not a plist".utf8), productType: "x") } + } + + @Test func catalogIsCachedForADay() async throws { + struct Fetcher: DataFetching { + let calls = LockedValue(0) + func data(from url: URL, limit: Int) async throws -> Data { calls.withLock { $0 += 1 }; return Data(FirmwareTests.catalog.utf8) } + } + let folder = try SecureFileIO.makeTemporaryDirectory(prefix: "catalog") + defer { try? FileManager.default.removeItem(at: folder) } + let cache = folder.appendingPathComponent("version.plist") + let fetcher = Fetcher() + _ = try await FirmwareCatalog.load(cache: cache, fetcher: fetcher) + _ = try await FirmwareCatalog.load(cache: cache, fetcher: fetcher) + #expect(fetcher.calls.current == 1) + _ = try await FirmwareCatalog.load(cache: cache, fetcher: fetcher, now: Date().addingTimeInterval(2 * 86_400)) + #expect(fetcher.calls.current == 2) + } + + // MARK: Manifest and signing + + @Test func readsTheBuildManifestAndPicksTheIdentity() throws { + let manifest = try FirmwareManifest.parse(try Self.manifestData()) + #expect(manifest.productVersion == "27.0.1" && manifest.productBuild == "24A446") + #expect(manifest.supportedProductTypes == ["iPhone18,1"]) + #expect(manifest.identities.count == 3) + #expect(manifest.identity(chipID: 0x8150, boardID: 0x0E)?.boardID == 0x0E) + #expect(manifest.identity()?.restoreBehavior == "Erase", "an erase identity is preferred") + #expect(manifest.identity(behavior: "Update")?.restoreBehavior == "Update") + #expect(manifest.identity(chipID: 0x8140) == nil) + #expect(throws: ToolkitError.self) { try FirmwareManifest.parse(try PlistValue.dictionary(["x": 1]).encoded(format: .xml)) } + } + + @Test func asksAppleWhetherTheFirmwareIsSigned() async throws { + let identity = try #require(try FirmwareManifest.parse(try Self.manifestData()).identity()) + let request = FirmwareSigning.request(identity: identity, ecid: 42, nonce: Data(repeating: 5, count: 32)) + #expect(request["ApChipID"]?.intValue == 0x8150 && request["ApBoardID"]?.intValue == 0x0C) + #expect(request["ApECID"]?.intValue == 42 && request["ApNonce"]?.dataValue == Data(repeating: 5, count: 32)) + #expect(request["UniqueBuildID"]?.dataValue == Data([1, 2, 3, 4])) + #expect(request["KernelCache"]?["Digest"]?.dataValue == Data(repeating: 7, count: 48)) + #expect(request["KernelCache"]?["Info"] == nil && request["KernelCache"]?["EPRO"]?.boolValue == true) + #expect(request["Untrusted"] == nil, "only trusted components, or ones with request rules, are signed") + #expect(request["Ap,Rules"]?["EPRO"]?.boolValue == true, "request rules are applied") + #expect(request["BasebandFirmware"] == nil && request["@BBTicket"] == nil, "the baseband is signed in its own request") + #expect(request["Ap,ProductType"]?.stringValue == "iPhone18,1" && request["Ap,OSLongVersion"]?.stringValue == "27.0.1.24A446") + #expect(request["ApSecurityDomain"]?.intValue == 1 && request["SepNonce"]?.dataValue?.count == 20) + // A random ECID is used by default, never a device's. + #expect(FirmwareSigning.request(identity: identity)["ApECID"]?.intValue != FirmwareSigning.request(identity: identity)["ApECID"]?.intValue) + + #expect(FirmwareSigning.status(fromResponse: Data("STATUS=0&MESSAGE=SUCCESS&REQUEST_STRING=".utf8)) == .signed) + #expect(FirmwareSigning.status(fromResponse: Data("STATUS=94&MESSAGE=This device isn't eligible for the requested build.".utf8)) == .notSigned) + guard case .unknown = FirmwareSigning.status(fromResponse: Data("STATUS=128&MESSAGE=nope".utf8)) else { Issue.record("expected unknown"); return } + + struct Transport: PersonalizationTransport { + let reply: String + func send(_ body: Data) async throws -> Data { + #expect(try PlistValue.decode(body)["@ApImg4Ticket"]?.boolValue == true) + return Data(reply.utf8) + } + } + #expect(await FirmwareSigning.check(identity: identity, transport: Transport(reply: "STATUS=94&MESSAGE=not eligible")) == .notSigned) + #expect(await FirmwareSigning.check(identity: identity, transport: Transport(reply: "STATUS=0&MESSAGE=SUCCESS")) == .signed) + } + + // MARK: Remote and local IPSWs + + @Test func readsTheManifestFromAnIPSWOnAServer() async throws { + struct Server: RangeFetching { + let file: Data + let requested = LockedValue(0) + func size(of url: URL) async throws -> UInt64 { UInt64(file.count) } + func data(_ url: URL, range: ClosedRange) async throws -> Data { + requested.withLock { $0 += Int(range.upperBound - range.lowerBound + 1) } + return file.subdata(in: Int(range.lowerBound)..([]), lines = LockedValue<[String]>([]) + _ = try await FirmwareInstall.run(request, runner: runner, progress: { step, fraction in steps.withLock { $0.append("\(step) \(fraction)") } }, line: { line in lines.withLock { $0.append(line) } }) + #expect(steps.current == ["Finding the device 0.5", "Sending the system 0.25"]) + #expect(lines.current == ["Found device in Recovery mode"]) + + let failing = StreamingRunner(chunks: [.standardError(Data("ERROR: Unable to discover device mode.\n".utf8))], exitCode: 255) + do { + _ = try await FirmwareInstall.run(request, runner: failing, progress: { _, _ in }, line: { _ in }) + Issue.record("expected a failure") + } catch let error as ToolkitError { + #expect(error.message.contains("could not be found")) + } + + let recovery = StreamingRunner(chunks: [.standardOutput(Data("MODE: DFU\nECID: 0x1A\nPRODUCT: iPhone18,1\n".utf8))], exitCode: 0) + #expect(await RecoveryProbe.query(runner: recovery, helper: URL(fileURLWithPath: "/x/irecovery"))?.mode == .dfu) + #expect(await RecoveryProbe.query(runner: StreamingRunner(chunks: [], exitCode: 255), helper: URL(fileURLWithPath: "/x/irecovery")) == nil) + } + + @Test func checksTheFirmwareBeforeInstalling() throws { + let folder = try SecureFileIO.makeTemporaryDirectory(prefix: "preflight") + defer { try? FileManager.default.removeItem(at: folder) } + let file = try IPSWLibrary.inspect(try Self.fakeIPSW(in: folder)) + let good = FirmwarePreflight.localChecks(ipsw: file, productType: "iPhone18,1", deviceClass: "V53AP", mode: .update) + #expect(good.allSatisfy { $0.passed == true }) + let otherModel = FirmwarePreflight.localChecks(ipsw: file, productType: "iPhone17,1", deviceClass: "v53ap", mode: .restore) + #expect(otherModel.first?.passed == false && otherModel.first?.detail.contains("not iPhone17,1") == true) + #expect(FirmwarePreflight.localChecks(ipsw: file, productType: nil, deviceClass: "d47ap", mode: .restore).map(\.passed) == [false]) + #expect(FirmwarePreflight.signingCheck(.signed).passed == true) + #expect(FirmwarePreflight.signingCheck(.notSigned).passed == false) + #expect(FirmwarePreflight.signingCheck(.unknown("offline")).passed == nil) + #expect(try #require(FirmwareManifest.parse(try Self.manifestData()).identity(deviceClass: "V53AP")).deviceClass == "v53ap") + } +} + +private final class BundleMarker {} + +/// Streams scripted output, then finishes with `exitCode`. +struct StreamingRunner: CommandRunning { + let chunks: [CommandStreamEvent] + let exitCode: Int32 + + func run(_ request: CommandRequest) async throws -> CommandResult { + var output = Data(), errors = Data() + for chunk in chunks { + if case .standardOutput(let data) = chunk { output += data } + if case .standardError(let data) = chunk { errors += data } + } + return CommandResult(request: request, termination: .exited(exitCode), standardOutput: output, standardError: errors, startedAt: Date(), finishedAt: Date()) + } + + func stream(_ request: CommandRequest) -> AsyncThrowingStream { + let chunks = chunks, exitCode = exitCode + return AsyncThrowingStream { continuation in + for chunk in chunks { continuation.yield(chunk) } + continuation.yield(.finished(CommandResult(request: request, termination: .exited(exitCode), standardOutput: Data(), standardError: Data(), startedAt: Date(), finishedAt: Date()))) + continuation.finish() + } + } +} diff --git a/Tests/ToolkitFeaturesTests/RealFirmwareTests.swift b/Tests/ToolkitFeaturesTests/RealFirmwareTests.swift new file mode 100644 index 0000000..d51a690 --- /dev/null +++ b/Tests/ToolkitFeaturesTests/RealFirmwareTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import ToolkitFeatures +import ToolkitCore + +/// Read-only checks against Apple's live services and the built helpers. Opt in with +/// IDT_NETWORK_TESTS=1 (and IDT_RESTORE_HELPERS=). +/// Nothing is downloaded beyond the catalog and a few hundred kilobytes of one IPSW, and no +/// device is changed. +@Suite("Real firmware services (opt-in)", .serialized, .enabled(if: ProcessInfo.processInfo.environment["IDT_NETWORK_TESTS"] == "1")) +struct RealFirmwareTests { + func report(_ text: String) { print("[firmware] \(text)") } + + @Test(.timeLimit(.minutes(5))) + func appleCatalogManifestAndSigning() async throws { + let catalog = try await URLSessionDataFetcher().data(from: FirmwareCatalog.url, limit: 64 << 20) + let releases = try FirmwareCatalog.releases(fromCatalog: catalog, productType: "iPhone18,1") + let latest = try #require(releases.first) + report("catalog: \(catalog.count) bytes; iPhone18,1 → \(releases.map { "\($0.version) (\($0.build))" }.joined(separator: ", ")); sha1 \(latest.sha1 ?? "none")") + #expect(latest.url.scheme == "https") + + let started = Date() + let manifest = try FirmwareManifest.parse(try await RemoteArchive.file(named: FirmwareManifest.fileName, in: latest.url)) + report("remote BuildManifest in \(String(format: "%.1f", Date().timeIntervalSince(started))) s: \(manifest.productVersion) (\(manifest.productBuild)), \(manifest.identities.count) identities, models \(manifest.supportedProductTypes.joined(separator: ","))") + #expect(manifest.productBuild == latest.build) + #expect(manifest.supportedProductTypes.contains("iPhone18,1")) + + let identity = try #require(manifest.identity()) + let status = await FirmwareSigning.check(identity: identity) + report("Apple signing status for \(manifest.productVersion) (\(manifest.productBuild)): \(status.label) — \(status.explanation)") + #expect(status == .signed, "Apple lists this firmware as current, so it should be signed") + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["IDT_RESTORE_HELPERS"] != nil)) + func bundledHelpersRun() async throws { + let runner = ProcessCommandRunner() + let restore = try RestoreHelper.idevicerestore.locate() + let version = try await runner.run(CommandRequest(executable: restore, arguments: ["--version"], timeout: 30, displayName: "idevicerestore --version")) + report("idevicerestore --version: \(version.standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines))") + #expect(version.succeeded && version.standardOutputText.contains("idevicerestore")) + let query = try await runner.run(RecoveryProbe.queryRequest(helper: try RestoreHelper.irecovery.locate())) + let found = RecoveryProbe.parse(query.standardOutputText + query.standardErrorText) + report("irecovery -q: exit \(query.exitCode.map(String.init) ?? "?"), device in recovery/DFU: \(found.map { $0.mode.label } ?? "none")") + #expect(found == nil || query.succeeded) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 780ac79..c72e583 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,8 @@ creates pairing records. ## Developer images Developer-image support lives in `Sources/DeviceKit/DeveloperImage` and is the only code that -talks to the private `com.apple.mobile.mobile_image_mounter` service or to Apple's signing server: +talks to the private `com.apple.mobile.mobile_image_mounter` service; it and the firmware code +below are the only code that talks to Apple's signing server: | Type | Role | |---|---| @@ -52,6 +53,20 @@ The private service and TSS formats follow the open-source implementations the 0 they are covered by tests against a stateful fake image mounter, and still need verification on physical devices ([PHYSICAL_DEVICE_TEST_PROTOCOL.md](PHYSICAL_DEVICE_TEST_PROTOCOL.md), Stage 3). +## Firmware + +Firmware support lives in `Sources/ToolkitFeatures/Firmware`: + +| Type | Role | +|---|---| +| `FirmwareCatalog` | Apple's firmware list (`itunes.apple.com/check/version`), cached for a day | +| `FirmwareManifest`, `FirmwareSigning` | `BuildManifest.plist` build identities; the AP signing request built as libtatsu builds it, with a random ECID and nonce, to learn whether Apple signs a build | +| `RemoteArchive`, `IPSWLibrary`, `FirmwareDownloader` | One file from a remote IPSW by HTTP range requests; the local library, SHA-1/SHA-256; resumable, verified downloads | +| `RestoreHelper`, `RecoveryProbe`, `FirmwareInstall`, `FirmwarePreflight` | The bundled `idevicerestore` and `irecovery` (separate LGPL programs in `Contents/Helpers`, built by `scripts/build-restore-helpers.sh`): locating them, recovery/DFU detection, install command vectors, progress, failure explanations, and the checks made before installing | + +The helpers are the one place the app runs third-party programs it ships; see +[MIGRATION.md](../MIGRATION.md#10-firmware-ipsw-manager-and-installation) for why. + ## Processes `CommandRunner` in ToolkitCore is the only place that creates a `Process`. A request is an diff --git a/docs/release-verification.md b/docs/release-verification.md index 66b0380..1999731 100644 --- a/docs/release-verification.md +++ b/docs/release-verification.md @@ -7,9 +7,10 @@ a GitHub-hosted macOS runner: | File | Contents | |---|---| -| `iOS-Developer-Toolkit-Swift-VERSION-macOS-universal.zip` | The app (arm64 + x86_64), ad-hoc signed with the hardened runtime, with `idt` in `Contents/MacOS` and dependency licenses in `Contents/Resources/Licenses` | +| `iOS-Developer-Toolkit-Swift-VERSION-macOS-universal.zip` | The app (arm64 + x86_64), ad-hoc signed with the hardened runtime, with `idt` in `Contents/MacOS`, the firmware helpers (`idevicerestore`, `irecovery`) in `Contents/Helpers`, and dependency licenses in `Contents/Resources/Licenses` | | `SHA256SUMS.txt` | SHA-256 of every release file | | `iOS-Developer-Toolkit-Swift-VERSION.spdx.json` | SPDX 2.3 SBOM of the Swift package dependencies, generated from `Package.resolved` | +| `iOS-Developer-Toolkit-Swift-VERSION-firmware-helpers-source.tar.gz` | The complete source of the bundled firmware helpers (pinned libimobiledevice projects, libzip, OpenSSL) and the script that builds them | GitHub build-provenance and SBOM attestations are published for the ZIP. The app is **not** notarized by Apple. diff --git a/docs/safety.md b/docs/safety.md index 2fb62ed..bf0e296 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -15,8 +15,8 @@ explains private vulnerability reporting and what must never go into a public is |---|---|---| | Read-only | Device details, battery, lock state, app list | Runs immediately; the target is always visible | | Saves files on this Mac | Screenshot, crash reports, backup, capture | Review sheet with the destination; files are never overwritten | -| Changes the device | Install or launch an app, set a location, mount or unmount the developer image | Type `RUN` and the last six characters of the target's UDID | -| High impact | Restart, remove an app, erase a simulator | Confirm a current backup, then type `IRREVERSIBLE` and the same six characters | +| Changes the device | Install or launch an app, set a location, mount or unmount the developer image, enter or leave recovery mode, update firmware | Type `RUN` and the last six characters of the target's UDID | +| High impact | Restart, remove an app, erase a simulator, restore (erase and reinstall) firmware | Confirm a current backup, then type `IRREVERSIBLE` and the same six characters | The Command Palette and Actions list only what is available for the selected target and re-check eligibility when you run it. Advanced Mode classifies `devicectl` subcommands the same diff --git a/iOSDeveloperToolkit.xcodeproj/project.pbxproj b/iOSDeveloperToolkit.xcodeproj/project.pbxproj index f41ca66..ba3b9e6 100644 --- a/iOSDeveloperToolkit.xcodeproj/project.pbxproj +++ b/iOSDeveloperToolkit.xcodeproj/project.pbxproj @@ -18,10 +18,12 @@ 26019A7B916688FEE3A23585 /* LogViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81EECBCB7D517436001EFF8 /* LogViews.swift */; }; 296F9F5D4685AB10A7B27CA6 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791078D9EF3EC6308839270F /* AppModel.swift */; }; 42E12E11EB7BA75B9D50C3E8 /* ReconnectGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26E835D20074A0489176A15 /* ReconnectGuideView.swift */; }; + 4610E70974BDF2EAD32B8303 /* FirmwareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32872FC2AF66DD024EA035E2 /* FirmwareView.swift */; }; 58CE6E900B85D91761091C12 /* ShortcutReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A5A2B1435E385D65A1EA31 /* ShortcutReferenceView.swift */; }; 743B753A384188F88D118565 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6641462093807C7726C77E6 /* ContentView.swift */; }; 74E2D8771554772F6F5085DB /* DeveloperImageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A42E01F498D8AE9BAE52EA83 /* DeveloperImageModel.swift */; }; 795941F1191B8EF4997DF250 /* AppsViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF4B82A9EAB2FEBF4FBE2FA2 /* AppsViews.swift */; }; + 7E5687D6621795076AAED626 /* FirmwareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA238102E7E6CB9B5DB927EC /* FirmwareModel.swift */; }; 8B67289B01733ABC7CEAE819 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F724AF45C9EBAFFC75A5710 /* Components.swift */; }; 932BE70EA34036FB92844B1C /* LocationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585ECC84A09B869053F28246 /* LocationModel.swift */; }; 99273540613D8AD2D5573319 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1A39B4EB7A1BB72ED4812024 /* DeviceKit */; }; @@ -48,6 +50,7 @@ 117839C38E19BA74C82D8382 /* ReferenceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceViews.swift; sourceTree = ""; }; 192082EDB2AA8A31B4DE9A43 /* DeviceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceViews.swift; sourceTree = ""; }; 2095C064BD8BE67389318E84 /* SmokeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmokeUITests.swift; sourceTree = ""; }; + 32872FC2AF66DD024EA035E2 /* FirmwareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareView.swift; sourceTree = ""; }; 50A5A2B1435E385D65A1EA31 /* ShortcutReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutReferenceView.swift; sourceTree = ""; }; 585ECC84A09B869053F28246 /* LocationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationModel.swift; sourceTree = ""; }; 599C314C6B21A9A615B04C7A /* iOSDeveloperToolkit.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOSDeveloperToolkit.entitlements; sourceTree = ""; }; @@ -66,6 +69,7 @@ C6641462093807C7726C77E6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; CD1BCA4BFD074C07318C65CC /* LiveLogsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLogsModel.swift; sourceTree = ""; }; D34F678B9113D08044E08644 /* FeatureModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureModels.swift; sourceTree = ""; }; + DA238102E7E6CB9B5DB927EC /* FirmwareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareModel.swift; sourceTree = ""; }; DC1B9D11BA0887FAFECDC9DF /* DeveloperImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperImageView.swift; sourceTree = ""; }; E7E525F882B8476846CFA326 /* ReadinessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadinessView.swift; sourceTree = ""; }; EC558873395BD2D10988B49F /* LocationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationViews.swift; sourceTree = ""; }; @@ -94,6 +98,7 @@ 791078D9EF3EC6308839270F /* AppModel.swift */, A42E01F498D8AE9BAE52EA83 /* DeveloperImageModel.swift */, D34F678B9113D08044E08644 /* FeatureModels.swift */, + DA238102E7E6CB9B5DB927EC /* FirmwareModel.swift */, CD1BCA4BFD074C07318C65CC /* LiveLogsModel.swift */, 585ECC84A09B869053F28246 /* LocationModel.swift */, F10F7407E0C32BF6634D85B5 /* ScreenshotHarness.swift */, @@ -136,6 +141,7 @@ B37EBEC3A6576895CA18665A /* DataViews.swift */, DC1B9D11BA0887FAFECDC9DF /* DeveloperImageView.swift */, 192082EDB2AA8A31B4DE9A43 /* DeviceViews.swift */, + 32872FC2AF66DD024EA035E2 /* FirmwareView.swift */, EC558873395BD2D10988B49F /* LocationViews.swift */, B81EECBCB7D517436001EFF8 /* LogViews.swift */, E7E525F882B8476846CFA326 /* ReadinessView.swift */, @@ -297,6 +303,8 @@ 1061CDB40FBAC1EEE77206D5 /* DeveloperImageView.swift in Sources */, 0046784D38773E756D16B381 /* DeviceViews.swift in Sources */, 9F456A81A2AF3E4B9BA9E1A0 /* FeatureModels.swift in Sources */, + 7E5687D6621795076AAED626 /* FirmwareModel.swift in Sources */, + 4610E70974BDF2EAD32B8303 /* FirmwareView.swift in Sources */, 1BEAAB3DA748A2FF696A37BE /* LiveLogsModel.swift in Sources */, 932BE70EA34036FB92844B1C /* LocationModel.swift in Sources */, 16410705637BF3C36BD60ADB /* LocationViews.swift in Sources */, diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 921a124..8af5bb2 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -1,5 +1,7 @@ #!/bin/bash -# Builds a release of iOS Developer Toolkit, locally or in CI. Needs only Xcode. +# Builds a release of iOS Developer Toolkit, locally or in CI. Needs Xcode, plus autoconf, +# automake, libtool, pkg-config, and cmake for the firmware helpers (see +# scripts/build-restore-helpers.sh; a build is reused while that script is unchanged). # # scripts/build-release.sh [VERSION] [OUTPUT_DIR] # @@ -10,11 +12,16 @@ # Produces, in OUTPUT_DIR: # iOS-Developer-Toolkit-Swift-VERSION-macOS-universal.zip the app (arm64 + x86_64), ad-hoc signed # with the hardened runtime; idt is at -# Contents/MacOS/idt and dependency licenses, +# Contents/MacOS/idt, the firmware helpers +# (idevicerestore, irecovery) are in +# Contents/Helpers, and dependency licenses, # notices, and the SBOM are in # Contents/Resources/Licenses # iOS-Developer-Toolkit-Swift-VERSION.spdx.json SPDX 2.3 SBOM from Package.resolved -# SHA256SUMS.txt checksums of both files +# iOS-Developer-Toolkit-Swift-VERSION-firmware-helpers-source.tar.gz +# the complete source of the bundled firmware +# helpers and the script that builds them +# SHA256SUMS.txt checksums of these files set -euo pipefail fail() { echo "build-release: $*" >&2; exit 1; } @@ -53,6 +60,7 @@ trap 'rm -rf "$WORK"' EXIT NAME="iOS-Developer-Toolkit-Swift-$VERSION" ZIP="$OUT/$NAME-macOS-universal.zip" SBOM="$OUT/$NAME.spdx.json" +HELPER_SOURCE="$OUT/$NAME-firmware-helpers-source.tar.gz" ENTITLEMENTS="App/iOSDeveloperToolkit/iOSDeveloperToolkit.entitlements" step "Xcode: $(xcodebuild -version | tr '\n' ' ')" @@ -75,6 +83,29 @@ fi BIN="$(swift build -c release --product idt --arch arm64 --arch x86_64 --scratch-path "$CACHE/spm" --show-bin-path)" cp "$BIN/idt" "$APP/Contents/MacOS/idt" +step "Firmware helpers (idevicerestore, irecovery)" +HELPERS="$ROOT/build-output/restore-helpers/out" +helper_stamp="$(shasum -a 256 scripts/build-restore-helpers.sh | cut -d' ' -f1)" +if [[ "$(cat "$HELPERS/STAMP" 2>/dev/null)" != "$helper_stamp" ]]; then + if ! scripts/build-restore-helpers.sh "$HELPERS" > "$WORK/helpers-build.log" 2>&1; then + tail -60 "$WORK/helpers-build.log" >&2 + fail "the firmware helpers did not build (output above)" + fi +fi +# The helpers' complete source, as the LGPL asks of anyone distributing them. +HELPER_SRC="$ROOT/build-output/restore-helpers/src" +mkdir -p "$WORK/helper-source/firmware-helpers-source" +for repo in "$HELPER_SRC"/*/.git; do + name="$(basename "$(dirname "$repo")")" + git -C "$HELPER_SRC/$name" archive --format=tar --prefix="$name/" HEAD | tar -x -C "$WORK/helper-source/firmware-helpers-source" +done +cp "$HELPER_SRC"/openssl-*.tar.gz scripts/build-restore-helpers.sh "$HELPERS/SOURCES.txt" "$WORK/helper-source/firmware-helpers-source/" +tar -czf "$HELPER_SOURCE" -C "$WORK/helper-source" firmware-helpers-source +mkdir -p "$APP/Contents/Helpers" +for helper in idevicerestore irecovery; do + cp "$HELPERS/bin/$helper" "$APP/Contents/Helpers/$helper" +done + step "Adding licenses, notices, and the SBOM" LICENSES="$APP/Contents/Resources/Licenses" mkdir -p "$LICENSES" @@ -92,27 +123,47 @@ for identity in $(sed -n 's/.*"identity" : "\(.*\)".*/\1/p' Package.resolved); d done [[ "$found" == 1 ]] || fail "no license file found for $identity" done +mkdir -p "$LICENSES/restore-helpers" +cp -R "$HELPERS/licenses/." "$LICENSES/restore-helpers/" +cp "$HELPERS/SOURCES.txt" "$LICENSES/restore-helpers/SOURCES.txt" xcrun swift scripts/generate-sbom.swift Package.resolved "$CACHE/spm/checkouts" "$VERSION" "$COMMIT" "$SBOM" cp "$SBOM" "$LICENSES/sbom.spdx.json" step "Signing (ad hoc, hardened runtime)" codesign --force --sign - --options runtime --timestamp=none \ --identifier io.hideouts.iOSDeveloperToolkit.idt "$APP/Contents/MacOS/idt" +for helper in idevicerestore irecovery; do + codesign --force --sign - --options runtime --timestamp=none \ + --identifier "io.hideouts.iOSDeveloperToolkit.$helper" "$APP/Contents/Helpers/$helper" +done codesign --force --sign - --options runtime --timestamp=none \ --entitlements "$ENTITLEMENTS" "$APP" +# The libraries a binary links. otool reads a path ending in "(…)" as an archive member, so the +# app's executable ("… (Swift)") is read through a link with a plain name. +linked_libraries() { + local link="$WORK/otool-target" + ln -sf "$1" "$link" + otool -L "$link" | grep -v ':$' | awk '{print $1}' + rm -f "$link" +} + verify_app() { local app="$1" codesign --verify --deep --strict "$app" || fail "codesign verification failed for $app" local details - for binary in "$app/Contents/MacOS/iOS Developer Toolkit (Swift)" "$app/Contents/MacOS/idt"; do + for binary in "$app/Contents/MacOS/iOS Developer Toolkit (Swift)" "$app/Contents/MacOS/idt" "$app/Contents/Helpers/idevicerestore" "$app/Contents/Helpers/irecovery"; do details="$(codesign --display --verbose=2 "$binary" 2>&1)" grep -q 'Signature=adhoc' <<<"$details" || fail "$(basename "$binary") is not ad-hoc signed" grep -Eq 'flags=0x[0-9a-f]+\(.*runtime' <<<"$details" || fail "$(basename "$binary") lacks the hardened runtime" local archs archs="$(lipo -archs "$binary")" [[ " $archs " == *" arm64 "* && " $archs " == *" x86_64 "* ]] || fail "$(basename "$binary") is not universal ($archs)" - if otool -L "$binary" | grep -qi python; then fail "$(basename "$binary") links Python"; fi + local libraries + libraries="$(linked_libraries "$binary")" || fail "otool could not read $(basename "$binary")" + [[ -n "$libraries" ]] || fail "otool listed no libraries for $(basename "$binary")" + if grep -qi python <<<"$libraries"; then fail "$(basename "$binary") links Python"; fi + if grep -Eq '^(/opt/homebrew|/usr/local)/' <<<"$libraries"; then fail "$(basename "$binary") links a Homebrew library"; fi done local plist_version plist_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" @@ -122,8 +173,13 @@ verify_app() { [[ "$idt_version" == "$VERSION" ]] || fail "idt reports $idt_version, expected $VERSION" if [[ "$(uname -m)" == arm64 ]] && arch -x86_64 /usr/bin/true 2>/dev/null; then [[ "$(arch -x86_64 "$app/Contents/MacOS/idt" --version)" == "$VERSION" ]] || fail "the x86_64 slice of idt does not run" + arch -x86_64 "$app/Contents/Helpers/idevicerestore" --version | grep -q '^idevicerestore ' || fail "the x86_64 slice of idevicerestore does not run" fi [[ -f "$app/Contents/Resources/Licenses/swift-nio-ssl/NOTICE.txt" ]] || fail "license notices are missing" + [[ -f "$app/Contents/Resources/Licenses/restore-helpers/idevicerestore/COPYING" && -f "$app/Contents/Resources/Licenses/restore-helpers/SOURCES.txt" ]] \ + || fail "the firmware helpers' licenses are missing" + "$app/Contents/Helpers/idevicerestore" --version | grep -q '^idevicerestore ' || fail "idevicerestore does not run" + "$app/Contents/Helpers/irecovery" --version | grep -q '^irecovery ' || fail "irecovery does not run" } step "Verifying the signed app" @@ -131,7 +187,7 @@ verify_app "$APP" step "Packaging" ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" -(cd "$OUT" && shasum -a 256 "$(basename "$ZIP")" "$(basename "$SBOM")" > SHA256SUMS.txt) +(cd "$OUT" && shasum -a 256 "$(basename "$ZIP")" "$(basename "$SBOM")" "$(basename "$HELPER_SOURCE")" > SHA256SUMS.txt) step "Verifying the ZIP" mkdir "$WORK/unzipped" diff --git a/scripts/build-restore-helpers.sh b/scripts/build-restore-helpers.sh new file mode 100755 index 0000000..e5da48c --- /dev/null +++ b/scripts/build-restore-helpers.sh @@ -0,0 +1,197 @@ +#!/bin/bash +# Builds the firmware helpers bundled with iOS Developer Toolkit (Swift): `idevicerestore` and +# `irecovery` from the libimobiledevice project, as universal (arm64 + x86_64) executables that +# link every third-party library statically and depend only on macOS system libraries. +# +# scripts/build-restore-helpers.sh [OUTPUT_DIR] +# +# OUTPUT_DIR (default build-output/restore-helpers/out) receives: +# bin/idevicerestore, bin/irecovery universal executables +# licenses//… each project's license and notices +# SOURCES.txt the exact source of every component +# STAMP SHA-256 of this script, so a build is reused only +# while the pinned sources and steps are unchanged +# +# Every source is pinned: git projects to a commit, OpenSSL to a release tarball and its published +# SHA-256. Needs Xcode, and autoconf, automake, libtool, pkg-config, and cmake (Homebrew). Builds +# happen under build-output/restore-helpers; nothing is installed on the system. +# +# The helpers are separate programs: the app runs them through CommandRunner and never links them. +# idevicerestore is LGPL-3.0; the libimobiledevice libraries are LGPL-2.1; libzip is BSD-3-Clause; +# OpenSSL is Apache-2.0. +set -euo pipefail + +fail() { echo "build-restore-helpers: $*" >&2; exit 1; } +step() { echo "==> $*"; } + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$ROOT/build-output/restore-helpers" +OUT="${1:-$WORK/out}" +SRC="$WORK/src" +MIN_MACOS=14.0 +JOBS="$(sysctl -n hw.ncpu)" + +# name|git URL|commit|version label +GIT_SOURCES=( + "libplist|https://github.com/libimobiledevice/libplist.git|32428abacb909988e8e960a8845a6430b17b6a60|2.7.0-git" + "libimobiledevice-glue|https://github.com/libimobiledevice/libimobiledevice-glue.git|da770a7687f35fbb981db4d7b47b1b032cd5c2c7|1.3.2-git" + "libusbmuxd|https://github.com/libimobiledevice/libusbmuxd.git|93eb168bf6b07472d17781328c21df0c60300524|2.1.1-git" + "libtatsu|https://github.com/libimobiledevice/libtatsu.git|e7d6ad13ef928aa609d0ccdfc586f7d6e8e049bf|1.0.5-git" + "libimobiledevice|https://github.com/libimobiledevice/libimobiledevice.git|fa0f79190142bc309307967c058f89c1b36eb6b8|1.4.0-git" + "libirecovery|https://github.com/libimobiledevice/libirecovery.git|93c117c29b1f6669bc4ceca8b84e1df06449fe33|1.3.1-git" + "idevicerestore|https://github.com/libimobiledevice/idevicerestore.git|60192e97f87d1bbab5c493684e0a245b0966363f|1.0.0-git" + "libzip|https://github.com/nih-at/libzip.git|6f8a0cdd24a0dc6cce9dac4a7679da784ab124ea|1.11.4" +) +OPENSSL_VERSION=3.5.8 +OPENSSL_URL="https://github.com/openssl/openssl/releases/download/openssl-$OPENSSL_VERSION/openssl-$OPENSSL_VERSION.tar.gz" +OPENSSL_SHA256=a8f84a39918ec6415ce765d9b429d313ba97b8143169c172e734b9514464f5b2 + +for tool in autoconf automake glibtoolize pkg-config cmake git xcrun lipo; do + command -v "$tool" >/dev/null || fail "$tool is missing (brew install autoconf automake libtool pkg-config cmake)" +done +SDK="$(xcrun --sdk macosx --show-sdk-path)" + +fetch_sources() { + mkdir -p "$SRC" + for entry in "${GIT_SOURCES[@]}"; do + IFS='|' read -r name url commit version <<<"$entry" + local dir="$SRC/$name" + if [[ -d "$dir/.git" && "$(git -C "$dir" rev-parse HEAD)" == "$commit" ]]; then continue; fi + step "Fetching $name @ ${commit:0:12}" + rm -rf "$dir"; mkdir -p "$dir" + git -C "$dir" init -q + git -C "$dir" fetch -q --depth 1 "$url" "$commit" + git -C "$dir" -c advice.detachedHead=false checkout -q FETCH_HEAD + [[ "$(git -C "$dir" rev-parse HEAD)" == "$commit" ]] || fail "$name is not at the pinned commit" + echo "$version" > "$dir/.tarball-version" + done + local tarball="$SRC/openssl-$OPENSSL_VERSION.tar.gz" + if [[ ! -f "$tarball" ]]; then + step "Downloading OpenSSL $OPENSSL_VERSION" + curl -fsSL -o "$tarball.part" "$OPENSSL_URL" + mv "$tarball.part" "$tarball" + fi + echo "$OPENSSL_SHA256 $tarball" | shasum -a 256 -c - >/dev/null || fail "OpenSSL tarball checksum mismatch" +} + +# autotools project: name, extra configure flags… +build_autotools() { + local arch="$1" name="$2"; shift 2 + local prefix="$WORK/prefix-$arch" build="$WORK/build-$arch/$name" + [[ -f "$build/.done" ]] && return 0 + step "[$arch] $name" + rm -rf "$build"; mkdir -p "$build" + cp -R "$SRC/$name/." "$build/" + ( + cd "$build" + export PKG_CONFIG_PATH="$prefix/lib/pkgconfig" PKG_CONFIG_LIBDIR="$prefix/lib/pkgconfig" + export CC="$(xcrun -f clang) -arch $arch -isysroot $SDK -mmacosx-version-min=$MIN_MACOS" + export CFLAGS="-O2" CPPFLAGS="-I$prefix/include" LDFLAGS="-L$prefix/lib" + export LIBTOOLIZE=glibtoolize + NOCONFIGURE=1 ./autogen.sh >"$build/autogen.log" 2>&1 || { tail -30 "$build/autogen.log"; exit 1; } + ./configure --host="$([[ $arch == arm64 ]] && echo aarch64 || echo x86_64)-apple-darwin" --prefix="$prefix" \ + --enable-static --disable-shared "$@" >"$build/configure.log" 2>&1 || { tail -40 "$build/configure.log"; exit 1; } + make -j"$JOBS" >"$build/make.log" 2>&1 || { grep -E "error" "$build/make.log" | head -20; tail -20 "$build/make.log"; exit 1; } + make install >"$build/install.log" 2>&1 + ) + touch "$build/.done" +} + +build_openssl() { + local arch="$1" prefix="$WORK/prefix-$arch" build="$WORK/build-$arch/openssl" + [[ -f "$build/.done" ]] && return 0 + step "[$arch] OpenSSL $OPENSSL_VERSION" + rm -rf "$build"; mkdir -p "$build" + tar -xzf "$SRC/openssl-$OPENSSL_VERSION.tar.gz" -C "$build" --strip-components 1 + ( + cd "$build" + ./Configure "darwin64-$arch-cc" no-shared no-tests no-docs no-module --prefix="$prefix" --libdir=lib \ + -isysroot "$SDK" -mmacosx-version-min="$MIN_MACOS" >"$build/configure.log" 2>&1 || { tail -30 "$build/configure.log"; exit 1; } + make -j"$JOBS" build_libs >"$build/make.log" 2>&1 || { tail -30 "$build/make.log"; exit 1; } + make install_dev >"$build/install.log" 2>&1 + ) + touch "$build/.done" +} + +build_libzip() { + local arch="$1" prefix="$WORK/prefix-$arch" build="$WORK/build-$arch/libzip" + [[ -f "$build/.done" ]] && return 0 + step "[$arch] libzip" + rm -rf "$build"; mkdir -p "$build" + cmake -S "$SRC/libzip" -B "$build" -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_OSX_ARCHITECTURES="$arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$MIN_MACOS" -DCMAKE_OSX_SYSROOT="$SDK" \ + -DCMAKE_INSTALL_PREFIX="$prefix" -DCMAKE_INSTALL_LIBDIR=lib \ + -DENABLE_BZIP2=OFF -DENABLE_LZMA=OFF -DENABLE_ZSTD=OFF -DENABLE_GNUTLS=OFF -DENABLE_MBEDTLS=OFF \ + -DENABLE_OPENSSL=OFF -DENABLE_COMMONCRYPTO=ON -DBUILD_TOOLS=OFF -DBUILD_REGRESS=OFF -DBUILD_OSSFUZZ=OFF \ + -DBUILD_EXAMPLES=OFF -DBUILD_DOC=OFF >"$build/cmake.log" 2>&1 || { tail -30 "$build/cmake.log"; exit 1; } + cmake --build "$build" -j "$JOBS" >"$build/make.log" 2>&1 || { tail -30 "$build/make.log"; exit 1; } + cmake --install "$build" >"$build/install.log" 2>&1 + touch "$build/.done" +} + +# pkg-config files for the libraries macOS provides (curl and zlib from the SDK). +system_pkgconfig() { + local prefix="$WORK/prefix-$1" + mkdir -p "$prefix/lib/pkgconfig" + cat > "$prefix/lib/pkgconfig/libcurl.pc" < "$prefix/lib/pkgconfig/zlib.pc" <> "$OUT/SOURCES.txt" +done +mkdir -p "$OUT/licenses/openssl" +tar -xzf "$SRC/openssl-$OPENSSL_VERSION.tar.gz" -C "$OUT/licenses/openssl" --strip-components 1 "openssl-$OPENSSL_VERSION/LICENSE.txt" +echo "openssl $OPENSSL_VERSION $OPENSSL_URL sha256 $OPENSSL_SHA256" >> "$OUT/SOURCES.txt" +shasum -a 256 "$ROOT/scripts/build-restore-helpers.sh" | cut -d' ' -f1 > "$OUT/STAMP" + +step "Done: $OUT" +ls -l "$OUT/bin" diff --git a/scripts/check-layout.sh b/scripts/check-layout.sh index 6456464..50a1af4 100755 --- a/scripts/check-layout.sh +++ b/scripts/check-layout.sh @@ -12,7 +12,7 @@ for size in 1180x700 900x560; do cat "$out/window-geometry.txt"; echo if [[ -e "$out/TIMEOUT" ]]; then echo "::error::screenshot harness timed out at $size"; exit 1; fi pages="$(grep -c ': window (' "$out/window-geometry.txt" || true)" - if (( pages < 15 )); then echo "::error::only $pages pages rendered at $size"; exit 1; fi + if (( pages < 16 )); then echo "::error::only $pages pages rendered at $size"; exit 1; fi if grep -E 'SQUEEZED|OVERFLOW' "$out/window-geometry.txt"; then echo "::error::layout problem at $size"; exit 1; fi done echo "layout OK"