From 4ea0d7fa98e7f3c86b89f181287e0956d6ee0feb Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:03:39 -0700 Subject: [PATCH 01/20] fix: bind app installs to inspected packages --- App/iOSDeveloperToolkit/Model/AppModel.swift | 9 + .../Model/FeatureModels.swift | 180 ++++++++-- App/iOSDeveloperToolkit/Views/AppsViews.swift | 41 ++- .../iOSDeveloperToolkitApp.swift | 52 ++- Sources/DeviceKit/Services/AFCClient.swift | 12 + .../ToolkitFeatures/IPA/IPAInspector.swift | 10 +- .../IPA/PreparedAppPackage.swift | 339 ++++++++++++++++++ Tests/DeviceKitTests/BackupAndAFCTests.swift | 40 ++- .../AppPackagePreparationTests.swift | 245 +++++++++++++ .../RealSimulatorTests.swift | 12 +- 10 files changed, 892 insertions(+), 48 deletions(-) create mode 100644 Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift create mode 100644 Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift diff --git a/App/iOSDeveloperToolkit/Model/AppModel.swift b/App/iOSDeveloperToolkit/Model/AppModel.swift index 5cfacf9..b842b3c 100644 --- a/App/iOSDeveloperToolkit/Model/AppModel.swift +++ b/App/iOSDeveloperToolkit/Model/AppModel.swift @@ -92,6 +92,7 @@ final class AppModel { } var journalRecords: [OperationRecord] = [] var operations: [RunningOperation] = [] + private(set) var isTerminating = false var presentedError: PresentedError? var developerTools: DeveloperToolsStatus? var readinessResults: [String: [CapabilityResult]] = [:] @@ -186,6 +187,14 @@ final class AppModel { security.cancel() } + func beginTermination() { + isTerminating = true + } + + func cancelTermination() { + isTerminating = false + } + private func apply(_ snapshot: DiscoverySnapshot) { let previous = Set(self.snapshot.devices.map(\.id)) self.snapshot = snapshot diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift index 860db24..0e7f32e 100644 --- a/App/iOSDeveloperToolkit/Model/FeatureModels.swift +++ b/App/iOSDeveloperToolkit/Model/FeatureModels.swift @@ -109,66 +109,204 @@ final class AppsModel { // MARK: - Install +/// Immutable input captured before the installation confirmation is presented. +struct AppInstallPlan: Sendable { + let package: PreparedAppPackage + let device: Device + let developerPackage: Bool +} + @Observable @MainActor final class InstallModel { - var packageURL: URL? - var inspection: IPAInspection? - var appBundleName: String? + private(set) var packageURL: URL? + private(set) var preparedPackage: PreparedAppPackage? + private var stalePackages: [PreparedAppPackage] = [] + private(set) var isInspecting = false + private(set) var inspectionError: String? + private(set) var isInstalling = false var installAsDeveloperPackage = false + private var selectionID = UUID() + private var inspectionOperation: RunningOperation? + private var installationOperation: RunningOperation? + private var activePreparations = 0 + private var isShuttingDown = false + private var cleanupWaiters: [CheckedContinuation] = [] + + var hasPreparedResources: Bool { preparedPackage != nil || !stalePackages.isEmpty || activePreparations > 0 || isInstalling } - var isAppBundle: Bool { packageURL?.pathExtension == "app" } + var inspection: IPAInspection? { preparedPackage?.inspection } + var appBundleName: String? { isAppBundle ? preparedPackage?.appName : nil } + var isAppBundle: Bool { packageURL?.pathExtension.lowercased() == "app" } func choose(_ url: URL, app: AppModel) async { + guard !isShuttingDown && !app.isTerminating else { + app.present(ToolkitError.invalidInput("The app is quitting. Cancel quitting before choosing another package.")) + return + } + guard !isInstalling else { + app.present(ToolkitError(.invalidInput, message: "Wait for the current installation to finish before choosing another package.", recovery: nil, technicalDetail: nil)) + return + } + let generation = UUID() + selectionID = generation + inspectionOperation?.cancel() + if let previous = preparedPackage { + do { try previous.remove() } + catch { + stalePackages.append(previous) + preparedPackage = nil + packageURL = nil + inspectionError = "The previous prepared package could not be removed. Review the reported file error before choosing another package." + app.present(error) + return + } + } packageURL = url - inspection = nil - appBundleName = nil - if url.pathExtension == "app" { - appBundleName = url.deletingPathExtension().lastPathComponent + preparedPackage = nil + inspectionError = nil + isInspecting = true + activePreparations += 1 + defer { + if selectionID == generation { + isInspecting = false + inspectionOperation = nil + } + activePreparations -= 1 + resumeCleanupIfIdle() + } + let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path], onStart: { self.inspectionOperation = $0 }) { _ in + let preparation = Task.detached(priority: .userInitiated) { try PreparedAppPackage.prepare(url) } + return try await withTaskCancellationHandler { + try await preparation.value + } onCancel: { preparation.cancel() } + } + guard selectionID == generation else { + if let result { + do { try result.remove() } + catch { + stalePackages.append(result) + app.present(error) + } + } return } - let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path]) { _ in - try IPAInspector.inspect(url) + preparedPackage = result + if result == nil { inspectionError = "Package inspection did not finish successfully. Choose the package again before installing." } + } + + func blockReason(for device: Device) -> String? { + if isShuttingDown { return "Package cleanup is running while the app quits." } + if isInstalling { return "An installation is already running." } + if isInspecting { return "Wait for package inspection to finish." } + guard let preparedPackage else { return inspectionError ?? "Choose and inspect a package before installing." } + do { + try preparedPackage.validate(for: device.target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted) + return nil + } catch { return (error as? ToolkitError)?.message ?? error.localizedDescription } + } + + func prepareInstallation(for device: Device, app: AppModel) throws -> AppInstallPlan { + guard !isInstalling && !isInspecting && !isShuttingDown, let preparedPackage else { + throw ToolkitError(.invalidInput, message: "Package inspection must finish successfully before installation.", recovery: "Choose the package again and wait for inspection.", technicalDetail: nil) + } + guard !app.isTerminating, app.selectedTarget == device.target else { + throw ToolkitError(.invalidInput, message: "The installation target changed before confirmation.", recovery: "Select the target again and review a new installation confirmation.", technicalDetail: nil) } - inspection = result + try preparedPackage.validate(for: device.target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted) + return AppInstallPlan(package: preparedPackage, device: device, developerPackage: installAsDeveloperPackage) } - func install(app: AppModel, device: Device) async { - guard let url = packageURL else { return } + func install(_ plan: AppInstallPlan, app: AppModel) async { + guard !isShuttingDown && !app.isTerminating else { + app.present(ToolkitError.invalidInput("The app is quitting. Cancel quitting before starting an app installation.")) + return + } + guard !isInstalling && !isInspecting, preparedPackage?.id == plan.package.id else { + app.present(ToolkitError(.invalidInput, message: "The package changed after this installation was confirmed.", recovery: "Inspect and confirm the selected package again.", technicalDetail: nil)) + return + } + guard app.selectedTarget == plan.device.target else { + app.present(ToolkitError(.invalidInput, message: "The installation target changed after confirmation.", recovery: "Select the target again and review a new installation confirmation.", technicalDetail: nil)) + return + } + let device = plan.device let target = device.target let runner = app.runner - let developer = installAsDeveloperPackage - let succeeded = await app.run("Install \(url.lastPathComponent)", workspace: .installApp, target: target, transport: device.kind == .simulator ? "simctl install" : (device.supportsCoreDevice ? "devicectl device install app" : "AFC + installation_proxy"), argv: [url.path]) { operation in + let url = plan.package.packageURL + isInstalling = true + defer { + isInstalling = false + installationOperation = nil + resumeCleanupIfIdle() + } + let succeeded = await app.run("Install \(plan.package.appName)", workspace: .installApp, target: target, transport: device.kind == .simulator ? "simctl install" : (device.supportsCoreDevice ? "devicectl device install app" : "AFC + installation_proxy"), argv: [plan.package.originalURL.path], onStart: { self.installationOperation = $0 }) { operation in + let validation = Task.detached(priority: .userInitiated) { try plan.package.revalidate() } + try await withTaskCancellationHandler { + try await validation.value + } onCancel: { validation.cancel() } + try Task.checkCancellation() + try plan.package.validate(for: target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted) switch device.kind { case .simulator: try await SimulatorClient(runner: runner).install(appAt: url, on: target) case .physical where device.supportsCoreDevice: _ = try await CoreDeviceClient(runner: runner).install(appAt: url, on: target) case .physical: - guard url.pathExtension == "ipa" else { throw ToolkitError(.unsupported, message: "Without Xcode, only .ipa packages can be installed.") } try await DeviceSession.with(target) { session in let afc = try await AFCClient.openMedia(session) let stagedName = "idt-\(UUID().uuidString.prefix(8)).ipa" - try? await afc.makeDirectory("/PublicStaging") + try await afc.ensureDirectory("/PublicStaging") try await afc.upload(url, to: "/PublicStaging/\(stagedName)") { sent, total in operation.report("Uploading \(ByteFormatting.string(sent)) of \(ByteFormatting.string(total))", progress: total > 0 ? Double(sent) / Double(total) * 0.5 : nil) } await afc.close() let proxy = try await InstallationProxy.open(session) defer { Task { await proxy.close() } } - try await proxy.install(stagedPackagePath: "PublicStaging/\(stagedName)", developerPackage: developer) { percent in + try await proxy.install(stagedPackagePath: "PublicStaging/\(stagedName)", developerPackage: plan.developerPackage) { percent in operation.report("Installing \(percent)%", progress: 0.5 + Double(percent) / 200) } } case .demo: - throw ToolkitError(.unsupported, message: "Demo Mode cannot install apps.") + throw ToolkitError(.unsupported, message: "Demo Mode cannot install apps.", recovery: nil, technicalDetail: nil) } return true } if succeeded == true { - app.statusMessage = "Installed \(inspection?.appName ?? url.lastPathComponent) on \(target.name)." - await app.apps.refresh(app: app, device: device) + app.statusMessage = "Installed \(plan.package.appName) on \(target.name)." + if app.selectedTarget == target { await app.apps.refresh(app: app, device: device) } + } + } + + /// Waits for this model's package readers before removing its private copy on normal quit. + func cleanupForTermination() async throws { + isShuttingDown = true + defer { isShuttingDown = false } + selectionID = UUID() + inspectionOperation?.cancel() + installationOperation?.cancel() + if activePreparations > 0 || isInstalling { + await withCheckedContinuation { cleanupWaiters.append($0) } } + if let preparedPackage { + try preparedPackage.remove() + self.preparedPackage = nil + } + while let stale = stalePackages.first { + try stale.remove() + stalePackages.removeFirst() + } + inspectionOperation = nil + packageURL = nil + inspectionError = nil + isInspecting = false + } + + private func resumeCleanupIfIdle() { + guard activePreparations == 0 && !isInstalling else { return } + let waiters = cleanupWaiters + cleanupWaiters.removeAll() + for waiter in waiters { waiter.resume() } } } diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index 06597e8..4987540 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -110,12 +110,21 @@ struct InstallAppView: View { Task { await install.choose(url, app: model) } } } + .disabled(install.isInstalling) .accessibilityIdentifier("choose-package") if let url = install.packageURL { Text(url.lastPathComponent).font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) } } } + if install.isInspecting { + ProgressView("Inspecting the selected package…") + .accessibilityIdentifier("package-inspection-running") + } else if let error = install.inspectionError { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + .accessibilityIdentifier("package-inspection-failed") + } if let inspection = install.inspection { InspectionView(inspection: inspection, device: model.selectedDevice) } else if let name = install.appBundleName { @@ -130,37 +139,33 @@ struct InstallAppView: View { Toggle("Install as developer package", isOn: $install.installAsDeveloperPackage) .help("Asks iOS to treat the package as a development build. Only meaningful for development-signed apps.") } - let blocked = blockReason(device) + let blocked = install.blockReason(for: device) if let blocked { Label(blocked, systemImage: "hand.raised").foregroundStyle(.orange) } Button("Install on \(device.name)…") { - confirmation = PendingConfirmation(title: "Install \(install.inspection?.appName ?? install.packageURL?.lastPathComponent ?? "app")?", detail: "The app will be installed on \(device.name). iOS still checks the signature, provisioning, and entitlements.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) { - Task { await install.install(app: model, device: device) } - } + do { + let plan = try install.prepareInstallation(for: device, app: model) + let developerOption = plan.device.kind == .physical && !plan.device.supportsCoreDevice + ? " Install as developer package: \(plan.developerPackage ? "on" : "off")." + : "" + confirmation = PendingConfirmation(title: "Install \(plan.package.appName)?", detail: "\(plan.package.originalURL.lastPathComponent) will be installed on \(plan.device.name).\(developerOption) iOS still checks the signature, provisioning, and entitlements.", requirement: .make(for: .deviceChange, target: plan.device.target), target: plan.device.target) { + Task { await install.install(plan, app: model) } + } + } catch { model.present(error) } } .disabled(blocked != nil) + .accessibilityIdentifier("install-package") } } } + .onChange(of: model.selectedTarget) { _, _ in confirmation = nil } + .onChange(of: install.preparedPackage?.id) { _, _ in confirmation = nil } + .onChange(of: install.installAsDeveloperPackage) { _, _ in confirmation = nil } .sheet(item: $confirmation) { pending in ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action) } } - - private func blockReason(_ device: Device) -> String? { - let install = model.install - switch device.kind { - case .demo: return "Demo Mode cannot install apps." - case .simulator: - if !install.isAppBundle { return "Simulators install .app bundles built for the simulator, not .ipa files." } - if device.simulatorState != .booted { return "Start the simulator first." } - return nil - case .physical: - if let inspection = install.inspection, !inspection.isInstallable { return inspection.installabilityExplanation } - return nil - } - } } struct InspectionView: View { diff --git a/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift b/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift index 9269709..96f67df 100644 --- a/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift +++ b/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift @@ -53,6 +53,8 @@ struct IOSDeveloperToolkitApp: App { final class AppDelegate: NSObject, NSApplicationDelegate { weak var model: AppModel? + private var terminationRequestID: UUID? + private var terminationDeadlineTask: Task? func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { MainActor.assumeIsolated { @@ -65,6 +67,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { MainActor.assumeIsolated { guard let model else { return .terminateNow } + guard !model.isTerminating else { return .terminateCancel } let busy = !model.operations.isEmpty || model.firmware.isInstalling || model.logs.sessions.contains { $0.state.isActive } let decision = FirmwareTerminationPolicy.decision(critical: model.firmware.terminationBlocked, busy: busy) if decision == .deny { @@ -87,14 +90,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate { explainFirmwareWriting() return .terminateCancel } - if model.firmware.isInstalling { + if model.firmware.isInstalling || model.install.hasPreparedResources { + model.beginTermination() + let requestID = UUID() + terminationRequestID = requestID Task { @MainActor in - guard await model.firmware.cancelForTermination() else { - self.explainFirmwareWriting() - sender.reply(toApplicationShouldTerminate: false) + var ready = true + var packageCleanupFailed = false + if model.firmware.isInstalling { + ready = await model.firmware.cancelForTermination() + if !ready { self.explainFirmwareWriting() } + } + if ready { + do { try await model.install.cleanupForTermination() } + catch { model.present(error); packageCleanupFailed = true; ready = false } + } + guard self.terminationRequestID == requestID else { + model.cancelTermination() return } - sender.reply(toApplicationShouldTerminate: self.finishTermination(model) == .terminateNow) + if ready { ready = self.finishTermination(model) == .terminateNow } + self.terminationRequestID = nil + self.terminationDeadlineTask?.cancel() + self.terminationDeadlineTask = nil + if !ready { model.cancelTermination() } + sender.reply(toApplicationShouldTerminate: ready) + if packageCleanupFailed { self.showTerminationFailureWithoutWindow(model) } + } + terminationDeadlineTask = Task { @MainActor in + do { try await Task.sleep(for: .seconds(8)) } + catch is CancellationError { return } + catch { model.present(error); return } + guard self.terminationRequestID == requestID else { return } + self.terminationRequestID = nil + model.present(ToolkitError(.timedOut, message: "Quit cleanup is still running after eight seconds. The app will remain open.", recovery: "Wait for the active operation to finish, then try quitting again.")) + sender.reply(toApplicationShouldTerminate: false) + self.showTerminationFailureWithoutWindow(model) } return .terminateLater } @@ -102,6 +133,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + @MainActor + private func showTerminationFailureWithoutWindow(_ model: AppModel) { + guard !NSApp.windows.contains(where: { $0.isVisible && $0.contentView != nil }), + let error = model.presentedError else { return } + let alert = NSAlert() + alert.messageText = error.title + alert.informativeText = [error.message, error.recovery].compactMap { $0 }.joined(separator: "\n\n") + alert.addButton(withTitle: "Keep Running") + alert.runModal() + } + @MainActor private func explainFirmwareWriting() { let alert = NSAlert() diff --git a/Sources/DeviceKit/Services/AFCClient.swift b/Sources/DeviceKit/Services/AFCClient.swift index 86ebe75..4ac5c60 100644 --- a/Sources/DeviceKit/Services/AFCClient.swift +++ b/Sources/DeviceKit/Services/AFCClient.swift @@ -154,6 +154,18 @@ public actor AFCClient { _ = try await transact(.makeDirectory, header: Self.path(path)) } + /// Accepts an existing real directory or creates a missing one. Other AFC failures propagate. + public func ensureDirectory(_ path: String) async throws { + do { + let info = try await fileInfo(path) + guard info.isDirectory, !info.isSymbolicLink else { + throw ToolkitError(.fileSystem, message: "The requested device directory is a file or symbolic link.", recovery: "Choose a regular device directory before continuing.") + } + } catch let error as ToolkitError where error == Self.error(code: 8) { + try await makeDirectory(path) + } + } + public func remove(_ path: String) async throws { _ = try await transact(.removePath, header: Self.path(path)) } diff --git a/Sources/ToolkitFeatures/IPA/IPAInspector.swift b/Sources/ToolkitFeatures/IPA/IPAInspector.swift index c6d812f..ee20cd9 100644 --- a/Sources/ToolkitFeatures/IPA/IPAInspector.swift +++ b/Sources/ToolkitFeatures/IPA/IPAInspector.swift @@ -95,6 +95,14 @@ public enum IPAInspector { } let appRoot = String(infoEntry.name.dropLast("/Info.plist".count)) let bundleIdentifier = try required("CFBundleIdentifier") + guard let platformValues = info["CFBundleSupportedPlatforms"]?.arrayValue, !platformValues.isEmpty else { + throw ToolkitError.invalidInput("The app's Info.plist must declare CFBundleSupportedPlatforms as a nonempty string array.") + } + let supportedPlatforms = platformValues.compactMap(\.stringValue) + guard supportedPlatforms.count == platformValues.count, + supportedPlatforms.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { + throw ToolkitError.invalidInput("CFBundleSupportedPlatforms contains a non-string or empty value.") + } let provisioning: ProvisioningProfileSummary if let profileEntry = archive.entry(named: "\(appRoot)/embedded.mobileprovision") { @@ -122,7 +130,7 @@ public enum IPAInspector { build: try required("CFBundleVersion"), minimumOSVersion: info["MinimumOSVersion"]?.stringValue, executableName: try required("CFBundleExecutable"), - supportedPlatforms: info["CFBundleSupportedPlatforms"]?.arrayValue?.compactMap(\.stringValue) ?? [], + supportedPlatforms: supportedPlatforms, provisioning: provisioning, signature: signature ) diff --git a/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift new file mode 100644 index 0000000..2f35967 --- /dev/null +++ b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift @@ -0,0 +1,339 @@ +import CryptoKit +import Darwin +import DeviceKit +import Foundation +import ToolkitCore + +/// An inspected private copy bound to the original package's content. Installation consumes +/// `packageURL`, after revalidation, and cleanup removes only this preparation's scratch folder. +public struct PreparedAppPackage: Sendable { + public let id: UUID + public let originalURL: URL + public let packageURL: URL + public let directory: URL + public let inspection: IPAInspection? + public let appName: String + public let supportedPlatforms: [String] + public let signature: CodeSignatureSummary? + + private let expectedIdentity: String + private let directoryDevice: dev_t + private let directoryInode: ino_t + private static let maximumBytes = ZipArchive.maximumTotalUncompressedBytes + + public static func prepare(_ source: URL) throws -> PreparedAppPackage { + try Task.checkCancellation() + guard source.isFileURL, ["ipa", "app"].contains(source.pathExtension.lowercased()) else { + throw ToolkitError.invalidInput("Choose an .ipa package or an iOS .app bundle.") + } + let original = source.standardizedFileURL + let identity = try packageIdentity(original) + let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-prepared-app") + let copy = directory.appendingPathComponent(original.lastPathComponent) + do { + try FileManager.default.copyItem(at: original, to: copy) + try Task.checkCancellation() + guard try packageIdentity(original) == identity, try packageIdentity(copy) == identity else { + throw ToolkitError(.invalidInput, message: "The package changed while it was being prepared.", recovery: "Choose the package again after its build or copy has finished.", technicalDetail: original.path) + } + let inspection: IPAInspection? + let appName: String + let platforms: [String] + let signature: CodeSignatureSummary? + if original.pathExtension.lowercased() == "ipa" { + let inspected = try IPAInspector.inspect(copy) + inspection = inspected + appName = inspected.appName + platforms = try validatedPlatforms(inspected.supportedPlatforms) + signature = inspected.signature + } else { + let info = try bundleInfo(copy) + inspection = nil + appName = info.name + platforms = info.platforms + signature = CodeSignatureInspector.inspect(bundle: copy) + } + guard try packageIdentity(copy) == identity, try packageIdentity(original) == identity else { + throw ToolkitError(.invalidInput, message: "The package changed during inspection.", recovery: "Choose the completed package again.", technicalDetail: original.path) + } + let ownership = try metadata(directory) + return PreparedAppPackage(id: UUID(), originalURL: original, packageURL: copy, directory: directory, inspection: inspection, appName: appName, supportedPlatforms: platforms, signature: signature, expectedIdentity: identity, directoryDevice: ownership.st_dev, directoryInode: ownership.st_ino) + } catch { + do { try FileManager.default.removeItem(at: directory) } + catch let cleanupError { + throw ToolkitError.wrap(error, message: "Could not prepare the app package.", recovery: "Choose a valid package again.").appendingDetail("Could not remove the incomplete private copy at \(directory.path): \(cleanupError.localizedDescription)") + } + throw error + } + } + + /// Checks captured metadata for the existing installation routes without filesystem I/O. + /// Call `revalidate()` immediately before execution. A physical device still decides provisioning. + public func validate(for target: DeviceTarget, supportsCoreDevice: Bool, simulatorBooted: Bool) throws { + switch target.kind { + case .demo: + throw ToolkitError(.unsupported, message: "Demo Mode does not install packages.", recovery: "Select a real simulator or an authorized physical device.", technicalDetail: nil) + case .simulator: + guard inspection == nil, packageURL.pathExtension.lowercased() == "app" else { + throw ToolkitError(.unsupported, message: "Simulators install .app bundles built for the simulator, not .ipa files.", recovery: "Build for an iOS Simulator destination and choose the resulting .app bundle.", technicalDetail: nil) + } + guard supportedPlatforms.contains("iPhoneSimulator") else { + throw ToolkitError(.unsupported, message: "This app bundle was not built for an iOS Simulator.", recovery: "Choose the .app from an iOS Simulator build.", technicalDetail: nil) + } + guard simulatorBooted else { + throw ToolkitError(.invalidInput, message: "Boot the selected simulator before installing the app.", recovery: "Boot it in Device & DDI, then confirm installation again.", technicalDetail: nil) + } + case .physical: + guard supportedPlatforms.contains("iPhoneOS") else { + throw ToolkitError(.unsupported, message: "This package was not built for a physical iOS device.", recovery: "Choose an iPhoneOS build with the required device signing and provisioning.", technicalDetail: nil) + } + if let inspection { + guard inspection.isInstallable else { + throw ToolkitError(.invalidInput, message: inspection.installabilityExplanation, recovery: "Rebuild and sign the package in Xcode, then choose it again.", technicalDetail: nil) + } + } else { + guard supportsCoreDevice else { + throw ToolkitError(.unsupported, message: "Installing a .app bundle on a physical device requires Xcode CoreDevice tools.", recovery: "Select Xcode or choose a signed .ipa for native installation.", technicalDetail: nil) + } + guard signature?.status == .valid else { + throw ToolkitError(.invalidInput, message: "The app bundle does not have an intact code signature.", recovery: "Rebuild and sign the iOS app in Xcode, then choose it again.", technicalDetail: signature?.detail) + } + } + } + } + + public func revalidate() throws { + try verifyDirectoryOwnership() + guard try Self.packageIdentity(originalURL) == expectedIdentity, try Self.packageIdentity(packageURL) == expectedIdentity else { + throw ToolkitError(.invalidInput, message: "The selected package or its prepared copy changed after inspection.", recovery: "Choose the package again and review a new installation confirmation.", technicalDetail: originalURL.path) + } + } + + public func remove() throws { + try verifyDirectoryOwnership() + do { try FileManager.default.removeItem(at: directory) } + catch { throw ToolkitError.fileSystem("Could not remove the prepared app package.", path: directory.path, underlying: error) } + } + + private func verifyDirectoryOwnership() throws { + let current = try Self.metadata(directory) + guard current.st_mode & S_IFMT == S_IFDIR, current.st_mode & 0o777 == 0o700, current.st_dev == directoryDevice, current.st_ino == directoryInode else { + throw ToolkitError.fileSystem("The private app-package folder was replaced. It will not be used or removed.", path: directory.path, underlying: nil) + } + } + + private static func validatedPlatforms(_ values: [String]) throws -> [String] { + guard !values.isEmpty, values.allSatisfy({ !$0.isEmpty }) else { + throw ToolkitError.invalidInput("The app's Info.plist must declare CFBundleSupportedPlatforms.") + } + return values + } + + private static func bundleInfo(_ bundle: URL) throws -> (name: String, platforms: [String]) { + let descriptor = try openChild(root: bundle, relativePath: "Info.plist") + let data = try readFile(descriptor, maximumBytes: IPAInspector.maximumInfoPlistBytes) + let info = try PlistValue.decode(data) + guard info.dictionaryValue != nil else { throw ToolkitError.invalidInput("The app's Info.plist is not a dictionary.") } + func required(_ key: String) throws -> String { + guard let value = info[key]?.stringValue, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ToolkitError.invalidInput("The app's Info.plist is missing \(key).") + } + return value + } + let identifier = try required("CFBundleIdentifier") + try BundleIdentifier.validate(identifier) + _ = try required("CFBundleShortVersionString") + _ = try required("CFBundleVersion") + let executable = try required("CFBundleExecutable") + guard !executable.contains("/"), !executable.contains("\0"), executable != ".", executable != ".." else { + throw ToolkitError.invalidInput("The app's CFBundleExecutable is not a safe filename.") + } + let executableInfo = try metadata(bundle.appendingPathComponent(executable)) + guard executableInfo.st_mode & S_IFMT == S_IFREG, executableInfo.st_mode & 0o111 != 0 else { + throw ToolkitError.invalidInput("The app's declared executable is missing or is not an executable regular file.") + } + guard let platformValues = info["CFBundleSupportedPlatforms"]?.arrayValue else { + throw ToolkitError.invalidInput("The app's Info.plist must declare CFBundleSupportedPlatforms as a string array.") + } + let platforms = platformValues.compactMap(\.stringValue) + guard platforms.count == platformValues.count else { throw ToolkitError.invalidInput("CFBundleSupportedPlatforms contains a non-string value.") } + let name: String + if info["CFBundleDisplayName"] != nil { name = try required("CFBundleDisplayName") } + else { name = try required("CFBundleName") } + return (name, try validatedPlatforms(platforms)) + } + + private struct TreeEntry: Codable { + let path: String + let kind: String + let permissions: mode_t + let hash: String? + let link: String? + } + + private static func packageIdentity(_ url: URL) throws -> String { + try Task.checkCancellation() + let info = try metadata(url) + if url.pathExtension.lowercased() == "ipa" { + guard info.st_mode & S_IFMT == S_IFREG else { throw ToolkitError.invalidInput("The IPA package must be a regular file, not a symbolic link.") } + let descriptor = open(url.path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw fileError("Could not open the IPA package safely.", url: url) } + return try hashFile(descriptor, url: url, maximumBytes: maximumBytes).hash + } + guard info.st_mode & S_IFMT == S_IFDIR else { throw ToolkitError.invalidInput("The app bundle must be a directory, not a symbolic link.") } + let root = url.resolvingSymlinksInPath() + var enumerationFailure: ToolkitError? + guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: [], options: [], errorHandler: { failedURL, error in + enumerationFailure = ToolkitError.fileSystem("Could not enumerate every app-bundle file.", path: failedURL.path, underlying: error) + return false + }) else { + throw ToolkitError.fileSystem("Could not inspect the app bundle's files.", path: root.path, underlying: nil) + } + var entries = [TreeEntry(path: "", kind: "directory", permissions: info.st_mode & 0o7777, hash: nil, link: nil)] + var totalBytes: UInt64 = 0 + for case let enumeratedURL as URL in enumerator { + try Task.checkCancellation() + guard entries.count < ZipArchive.maximumEntries else { throw ToolkitError.invalidInput("The app bundle exceeds the package file-count limit.") } + // FileManager may return /var URLs for a canonical /private/var root. Resolve only + // the parent so a leaf symbolic link remains visible to lstat and the identity. + let child = enumeratedURL.deletingLastPathComponent().resolvingSymlinksInPath().appendingPathComponent(enumeratedURL.lastPathComponent) + guard child.path.hasPrefix(root.path + "/") else { throw ToolkitError.invalidInput("An app-bundle path escaped its root.") } + let relativePath = String(child.path.dropFirst(root.path.count + 1)) + let fileMetadata = try metadata(child) + let kind = fileMetadata.st_mode & S_IFMT + switch kind { + case S_IFREG: + let descriptor = try openChild(root: root, relativePath: relativePath) + let file = try hashFile(descriptor, url: child, maximumBytes: maximumBytes - totalBytes) + totalBytes += file.bytes + entries.append(TreeEntry(path: relativePath, kind: "file", permissions: fileMetadata.st_mode & 0o7777, hash: file.hash, link: nil)) + case S_IFDIR: + guard child.resolvingSymlinksInPath().path.hasPrefix(root.path + "/") else { throw ToolkitError.invalidInput("An app-bundle directory escaped its root.") } + entries.append(TreeEntry(path: relativePath, kind: "directory", permissions: fileMetadata.st_mode & 0o7777, hash: nil, link: nil)) + case S_IFLNK: + enumerator.skipDescendants() + let link = try FileManager.default.destinationOfSymbolicLink(atPath: child.path) + let destination = child.deletingLastPathComponent().appendingPathComponent(link).standardizedFileURL + let resolved = child.resolvingSymlinksInPath() + guard !link.hasPrefix("/"), !link.contains("\0"), destination.path.hasPrefix(root.standardizedFileURL.path + "/"), resolved.path.hasPrefix(root.path + "/"), FileManager.default.fileExists(atPath: resolved.path) else { + throw ToolkitError.invalidInput("The app bundle contains an absolute, escaping, broken, or cyclic symbolic link: \(relativePath)") + } + entries.append(TreeEntry(path: relativePath, kind: "symlink", permissions: fileMetadata.st_mode & 0o7777, hash: nil, link: link)) + default: + throw ToolkitError.invalidInput("The app bundle contains a non-regular file: \(relativePath)") + } + } + if let enumerationFailure { throw enumerationFailure } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return SecureFileIO.sha256(of: try encoder.encode(entries.sorted { $0.path < $1.path })) + } + + /// Opens every directory component without following links, so a changed bundle path cannot + /// redirect a content read outside the bundle. Internal framework links are inventoried as links. + private static func openChild(root: URL, relativePath: String) throws -> Int32 { + var directory = open(root.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard directory >= 0 else { throw fileError("Could not open the app bundle safely.", url: root) } + var ownedDescriptors = [directory] + do { + let components = relativePath.split(separator: "/").map(String.init) + guard let last = components.last, !components.contains(".."), !components.contains(".") else { throw ToolkitError.invalidInput("An unsafe app-bundle path was rejected.") } + for component in components.dropLast() { + let next = openat(directory, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard next >= 0 else { throw fileError("Could not open an app-bundle directory safely.", url: root.appendingPathComponent(relativePath)) } + ownedDescriptors.append(next) + ownedDescriptors.removeAll { $0 == directory } + try closeDescriptor(directory, root: root) + directory = next + } + let descriptor = openat(directory, last, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw fileError("Could not open an app-bundle file safely.", url: root.appendingPathComponent(relativePath)) } + ownedDescriptors.append(descriptor) + ownedDescriptors.removeAll { $0 == directory } + try closeDescriptor(directory, root: root) + return descriptor + } catch { + var cleanupFailures: [String] = [] + for descriptor in ownedDescriptors { + do { try closeDescriptor(descriptor, root: root) } + catch { cleanupFailures.append((error as? ToolkitError)?.technicalDetail ?? error.localizedDescription) } + } + guard cleanupFailures.isEmpty else { + throw ToolkitError.wrap(error, message: "Could not open the app-bundle file safely.", recovery: "Choose the package again.").appendingDetail("Closing app-bundle descriptors also failed: \(cleanupFailures.joined(separator: "; "))") + } + throw error + } + } + + /// A close failure leaves descriptor ownership platform-dependent; surface it without retrying + /// a descriptor number that could already have been released and reused by another thread. + private static func closeDescriptor(_ descriptor: Int32, root: URL) throws { + guard close(descriptor) == 0 else { throw fileError("Could not close an app-bundle descriptor.", url: root) } + } + + private static func hashFile(_ descriptor: Int32, url: URL, maximumBytes: UInt64) throws -> (hash: String, bytes: UInt64) { + try withFileHandle(descriptor) { handle in + var info = stat() + guard fstat(descriptor, &info) == 0 else { throw fileError("Could not inspect the package file.", url: url) } + guard info.st_mode & S_IFMT == S_IFREG, info.st_size >= 0, UInt64(info.st_size) <= maximumBytes else { + throw ToolkitError.invalidInput("The package contains an invalid file or exceeds the 8 GB package preparation limit.") + } + var hasher = SHA256() + var bytes: UInt64 = 0 + while let data = try handle.read(upToCount: 1 << 20), !data.isEmpty { + try Task.checkCancellation() + guard UInt64(data.count) <= maximumBytes - bytes else { throw ToolkitError.invalidInput("The package changed size or exceeds the package preparation limit.") } + bytes += UInt64(data.count) + hasher.update(data: data) + } + return (hasher.finalize().map { String(format: "%02x", $0) }.joined(), bytes) + } + } + + private static func readFile(_ descriptor: Int32, maximumBytes: UInt64) throws -> Data { + try withFileHandle(descriptor) { handle in + var info = stat() + guard fstat(descriptor, &info) == 0 else { + throw ToolkitError.fileSystem("Could not read the app's Info.plist metadata.", path: nil, underlying: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) + } + guard info.st_mode & S_IFMT == S_IFREG, info.st_size >= 0, UInt64(info.st_size) <= maximumBytes else { + throw ToolkitError.invalidInput("The app's Info.plist must be a regular file within the 10 MB inspection limit.") + } + var data = Data() + while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { + try Task.checkCancellation() + guard UInt64(chunk.count) <= maximumBytes - UInt64(data.count) else { + throw ToolkitError.invalidInput("The app's Info.plist exceeds the 10 MB inspection limit.") + } + data.append(chunk) + } + return data + } + } + + private static func withFileHandle(_ descriptor: Int32, operation: (FileHandle) throws -> Value) throws -> Value { + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + let value: Value + do { value = try operation(handle) } + catch { + do { try handle.close() } + catch let cleanupError { + throw ToolkitError.wrap(error, message: "Could not read the app package.", recovery: "Choose the package again.").appendingDetail("Closing the package file also failed: \(cleanupError.localizedDescription)") + } + throw error + } + do { try handle.close() } + catch { throw ToolkitError.fileSystem("Could not close the app-package file after reading it.", path: nil, underlying: error) } + return value + } + + private static func metadata(_ url: URL) throws -> stat { + var info = stat() + guard lstat(url.path, &info) == 0 else { throw fileError("Could not read the package's file metadata.", url: url) } + return info + } + + private static func fileError(_ message: String, url: URL) -> ToolkitError { + ToolkitError.fileSystem(message, path: url.path, underlying: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) + } +} diff --git a/Tests/DeviceKitTests/BackupAndAFCTests.swift b/Tests/DeviceKitTests/BackupAndAFCTests.swift index 5995b4c..ea49472 100644 --- a/Tests/DeviceKitTests/BackupAndAFCTests.swift +++ b/Tests/DeviceKitTests/BackupAndAFCTests.swift @@ -103,7 +103,15 @@ struct BackupAndAFCTests { #expect(try Data(contentsOf: destination).count == 3000) let source = directory.appendingPathComponent("upload.ipa") try SecureFileIO.writeNewFile(Data(repeating: 7, count: 2500), to: source) - try await afc.makeDirectory("/PublicStaging") + try await afc.ensureDirectory("/PublicStaging") + try await afc.ensureDirectory("/PublicStaging") + #expect(try await afc.fileInfo("/PublicStaging").isDirectory) + do { + try await afc.ensureDirectory("/Downloads/readme.txt") + Issue.record("A regular file was accepted as a staging directory") + } catch let error as ToolkitError { + #expect(error.kind == .fileSystem) + } try await afc.upload(source, to: "/PublicStaging/upload.ipa", chunkSize: 1000) let walk = try await afc.walk("/") #expect(walk.contains("/PublicStaging/upload.ipa")) @@ -120,6 +128,36 @@ struct BackupAndAFCTests { } } + @Test func ensureDirectoryPropagatesDeniedInspectionWithoutCreating() async throws { + try await withFakeDevice({ _ in }) { server in + let operations = LockedValue<[UInt64]>([]) + server.register(service: AFCClient.mediaServiceName) { channel in + while try await channel.hasMoreData() { + let header = try await channel.read(exactly: 40, timeout: 5) + let length = Int(header.readLittleEndianUInt64(at: 8)) + let number = header.readLittleEndianUInt64(at: 24) + let operation = header.readLittleEndianUInt64(at: 32) + _ = try await channel.read(exactly: length - 40, timeout: 5) + operations.withLock { $0.append(operation) } + var status = Data() + status.appendLittleEndian(UInt64(10)) + try await channel.write(AFCClient.encode(operation: 0x01, packetNumber: number, header: status, payload: Data())) + } + } + try await DeviceSession.with(server.target, usbmux: server.client) { session in + let afc = try await AFCClient.openMedia(session) + do { + try await afc.ensureDirectory("/PublicStaging") + Issue.record("A denied staging-directory inspection succeeded") + } catch let error as ToolkitError { + #expect(error.kind == .permissionDenied) + } + await afc.close() + } + #expect(operations.current == [0x0A]) + } + } + @Test func fullBackupConversation() async throws { let statusPlist = try PlistValue(dictionaryLiteral: ("SnapshotState", "finished")).encoded() try await withFakeDevice({ $0.domainValues["com.apple.mobile.backup"] = ["WillEncrypt": true] }) { server in diff --git a/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift new file mode 100644 index 0000000..d4b8df1 --- /dev/null +++ b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift @@ -0,0 +1,245 @@ +import Darwin +import DeviceKit +import Foundation +import Testing +import ToolkitCore +@testable import ToolkitFeatures + +@Suite("App package preparation") +struct AppPackagePreparationTests { + @Test func unsignedSimulatorBundleHasAnImmutablePrivateCopyAndDistinctRouteValidation() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-simulator") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "SimulatorDemo", platform: "iPhoneSimulator") + let originalInfo = try Data(contentsOf: app.appendingPathComponent("Info.plist")) + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + + #expect(prepared.originalURL == app) + #expect(prepared.packageURL != app) + #expect(prepared.packageURL.lastPathComponent == app.lastPathComponent) + #expect(prepared.inspection == nil) + #expect(prepared.appName == "SimulatorDemo") + #expect(prepared.supportedPlatforms == ["iPhoneSimulator"]) + #expect((try FileManager.default.attributesOfItem(atPath: prepared.directory.path)[.posixPermissions] as? NSNumber)?.intValue == 0o700) + try prepared.revalidate() + try prepared.validate(for: target(.simulator), supportsCoreDevice: false, simulatorBooted: true) + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: false) } + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: true) } + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.demo), supportsCoreDevice: true, simulatorBooted: true) } + #expect(try Data(contentsOf: app.appendingPathComponent("Info.plist")) == originalInfo) + } + + private enum ChangedLocation: String, CaseIterable { + case original, prepared + } + + @Test(arguments: ChangedLocation.allCases) + private func bundleReplacementAtEitherLocationRequiresNewInspection(_ location: ChangedLocation) throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-app-change") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + let changed = location == .original ? app : prepared.packageURL + try Data("different resource".utf8).write(to: changed.appendingPathComponent("resource.txt")) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + let unchanged = location == .original ? prepared.packageURL : app + #expect(try Data(contentsOf: unchanged.appendingPathComponent("resource.txt")) == Data("resource".utf8)) + } + + @Test func bundleAddedFilesAndExecutablePermissionChangesAreDetected() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-app-mode") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + try SecureFileIO.writeNewFile(Data("new".utf8), to: prepared.packageURL.appendingPathComponent("new.txt")) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + try FileManager.default.removeItem(at: prepared.packageURL.appendingPathComponent("new.txt")) + try prepared.revalidate() + try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o600)], ofItemAtPath: app.appendingPathComponent("Demo").path) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + } + + @Test(.enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/codesign"))) + func physicalAppRequiresCoreDeviceAndAnIntactSignature() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-physical") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "DeviceDemo", platform: "iPhoneOS") + let unsigned = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(unsigned) } + #expect(throws: ToolkitError.self) { try unsigned.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: true) } + try await sign(app, in: root) + let signed = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(signed) } + #expect(signed.signature?.status == .valid) + try signed.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: false) + #expect(throws: ToolkitError.self) { try signed.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) } + #expect(throws: ToolkitError.self) { try signed.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: true) } + try Data("tampered".utf8).write(to: signed.packageURL.appendingPathComponent("resource.txt")) + #expect(throws: ToolkitError.self) { try signed.revalidate() } + } + + @Test func unsignedIPAStaysInspectableButCannotAuthorizeInstallation() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-unsigned-ipa") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneOS") + let ipa = try makeIPA(app, in: root, name: "Unsigned.ipa") + let prepared = try PreparedAppPackage.prepare(ipa) + defer { removeTestPackage(prepared) } + let inspection = try #require(prepared.inspection) + #expect(!inspection.isInstallable) + #expect(inspection.packagePath == prepared.packageURL.path) + #expect(inspection.appName == "Demo") + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) } + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: true) } + try prepared.revalidate() + try Data("replaced IPA".utf8).write(to: ipa) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + #expect(try Data(contentsOf: prepared.packageURL) != Data("replaced IPA".utf8)) + } + + @Test(.enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/codesign"))) + func signedIPAUsesTheApprovedCopyAndRetainsNativePhysicalInstallation() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-signed-ipa") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneOS") + try await sign(app, in: root) + let ipa = try makeIPA(app, in: root, name: "Signed.ipa") + let prepared = try PreparedAppPackage.prepare(ipa) + defer { removeTestPackage(prepared) } + #expect(prepared.inspection?.isInstallable == true) + try prepared.revalidate() + try prepared.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) + try prepared.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: false) + #expect(throws: ToolkitError.self) { try prepared.validate(for: target(.simulator), supportsCoreDevice: true, simulatorBooted: true) } + try Data("tampered copy".utf8).write(to: prepared.packageURL) + #expect(throws: ToolkitError.self) { try prepared.revalidate() } + } + + @Test func rejectsMalformedPackagesAndTypedBundleMetadata() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-malformed") + defer { removeTestItem(root) } + let malformedIPA = root.appendingPathComponent("Malformed.ipa") + try SecureFileIO.writeNewFile(Data("not ZIP".utf8), to: malformedIPA) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(malformedIPA) } + let unsupported = root.appendingPathComponent("Unsupported.zip") + try SecureFileIO.writeNewFile(Data(), to: unsupported) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(unsupported) } + let app = try makeApp(root, name: "Bad", platform: "iPhoneSimulator") + var info = try #require(infoPlist(name: "Bad", platform: "iPhoneSimulator").dictionaryValue) + info["CFBundleSupportedPlatforms"] = .array([.string("iPhoneSimulator"), .integer(1)]) + try PlistValue.dictionary(info).encoded(format: .xml).write(to: app.appendingPathComponent("Info.plist")) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + let malformedPlatformIPA = try makeIPA(app, in: root, name: "MalformedPlatform.ipa") + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(malformedPlatformIPA) } + info["CFBundleSupportedPlatforms"] = .array([.string("iPhoneSimulator")]) + info["CFBundleExecutable"] = .string("../outside") + try PlistValue.dictionary(info).encoded(format: .xml).write(to: app.appendingPathComponent("Info.plist")) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + try Data(repeating: 0x41, count: Int(IPAInspector.maximumInfoPlistBytes) + 1).write(to: app.appendingPathComponent("Info.plist")) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + } + + @Test func preservesSafeInternalFrameworkLinksWithoutFollowingThem() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-framework") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let framework = app.appendingPathComponent("Frameworks/Demo.framework") + try SecureFileIO.createPrivateDirectory(at: framework.appendingPathComponent("Versions/A")) + try SecureFileIO.writeNewFile(Data("framework resource".utf8), to: framework.appendingPathComponent("Versions/A/value.txt")) + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("Versions/Current").path, withDestinationPath: "A") + try FileManager.default.createSymbolicLink(atPath: framework.appendingPathComponent("value.txt").path, withDestinationPath: "Versions/Current/value.txt") + let prepared = try PreparedAppPackage.prepare(app) + defer { removeTestPackage(prepared) } + let copied = prepared.packageURL.appendingPathComponent("Frameworks/Demo.framework") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: copied.appendingPathComponent("Versions/Current").path) == "A") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: copied.appendingPathComponent("value.txt").path) == "Versions/Current/value.txt") + #expect(try Data(contentsOf: copied.appendingPathComponent("value.txt")) == Data("framework resource".utf8)) + try prepared.revalidate() + try prepared.validate(for: target(.simulator), supportsCoreDevice: false, simulatorBooted: true) + } + + @Test(arguments: ["../secret.txt", "/etc/passwd", "missing.txt", "link"]) + func deniesEscapingAbsoluteBrokenAndCyclicBundleLinks(_ destination: String) throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-unsafe-link") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let secret = root.appendingPathComponent("secret.txt") + try SecureFileIO.writeNewFile(Data("task-owned confidential test bytes".utf8), to: secret) + try FileManager.default.createSymbolicLink(atPath: app.appendingPathComponent("link").path, withDestinationPath: destination) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(app) } + #expect(try Data(contentsOf: secret) == Data("task-owned confidential test bytes".utf8)) + } + + @Test func rootSymbolicLinksAreDeniedAndCleanupDoesNotDeleteSource() throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-root-link") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let linked = root.appendingPathComponent("Linked.app") + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: app) + #expect(throws: ToolkitError.self) { try PreparedAppPackage.prepare(linked) } + let prepared = try PreparedAppPackage.prepare(app) + try prepared.remove() + #expect(!FileManager.default.fileExists(atPath: prepared.directory.path)) + #expect(FileManager.default.fileExists(atPath: app.appendingPathComponent("Demo").path)) + } + + private func removeTestItem(_ url: URL) { + do { try FileManager.default.removeItem(at: url) } + catch { Issue.record("Could not remove the task-owned package fixture: \(error.localizedDescription)") } + } + + private func removeTestPackage(_ package: PreparedAppPackage) { + do { try package.remove() } + catch { Issue.record("Could not remove the prepared package fixture: \(error.localizedDescription)") } + } + + private func infoPlist(name: String, platform: String) -> PlistValue { + .dictionary([ + "CFBundleIdentifier": .string("com.example.preparation"), + "CFBundleName": .string(name), + "CFBundleShortVersionString": .string("1.0"), + "CFBundleVersion": .string("1"), + "CFBundleExecutable": .string(name), + "CFBundleSupportedPlatforms": .array([.string(platform)]), + ]) + } + + private func makeApp(_ root: URL, name: String, platform: String) throws -> URL { + let app = root.appendingPathComponent("\(name).app") + try SecureFileIO.createPrivateDirectory(at: app) + try SecureFileIO.writeNewFile(try infoPlist(name: name, platform: platform).encoded(format: .xml), to: app.appendingPathComponent("Info.plist")) + try FileManager.default.copyItem(atPath: "/bin/echo", toPath: app.appendingPathComponent(name).path) + try SecureFileIO.writeNewFile(Data("resource".utf8), to: app.appendingPathComponent("resource.txt")) + return app + } + + private func makeIPA(_ app: URL, in root: URL, name: String) throws -> URL { + var writer = ZipWriter(date: Date(timeIntervalSince1970: 0)) + let appRoot = app.resolvingSymlinksInPath() + let enumerator = try #require(FileManager.default.enumerator(at: appRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [], errorHandler: nil)) + for case let enumeratedURL as URL in enumerator { + let file = enumeratedURL.resolvingSymlinksInPath() + guard try file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { continue } + guard file.path.hasPrefix(appRoot.path + "/") else { throw ToolkitError.invalidInput("The task-owned IPA fixture path escaped the app root.") } + let path = "Payload/\(app.lastPathComponent)/" + file.path.dropFirst(appRoot.path.count + 1) + let permissions = try #require(try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber) + try writer.add(name: path, data: Data(contentsOf: file), method: .stored, mode: permissions.uint32Value) + } + let url = root.appendingPathComponent(name) + try SecureFileIO.writeNewFile(writer.finalized(), to: url) + return url + } + + private func sign(_ app: URL, in root: URL) async throws { + let result = try await ProcessCommandRunner().run(CommandRequest(executable: URL(fileURLWithPath: "/usr/bin/codesign"), arguments: ["--force", "--sign", "-", app.path], workingDirectory: root, timeout: 60)) + #expect(result.succeeded, "\(result.standardErrorText)") + guard result.succeeded else { throw ToolkitError(.commandFailed, message: "Could not sign the task-owned test bundle.", recovery: nil, technicalDetail: result.technicalSummary) } + } + + private func target(_ kind: DeviceKind) -> DeviceTarget { + DeviceTarget(kind: kind, udid: "TASK-OWNED-TEST", name: "Synthetic Test Target", osVersion: "27.0", usbmuxDeviceID: nil, coreDeviceIdentifier: nil, transport: kind == .physical ? .usb : .local) + } +} diff --git a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift index b8ca232..bc8e27f 100644 --- a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift +++ b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift @@ -112,8 +112,16 @@ struct RealSimulatorTests { // Install, list, launch, and remove a real (minimal) simulator app. step("building fixture app") let fixture = try await Self.buildFixtureApp(in: directory) - step("installing fixture app") - try await client.install(appAt: fixture, on: target) + let prepared = try PreparedAppPackage.prepare(fixture) + defer { + do { try prepared.remove() } + catch { Issue.record("Could not remove the prepared simulator fixture: \(error.localizedDescription)") } + } + #expect(prepared.packageURL != fixture) + try prepared.revalidate() + try prepared.validate(for: target, supportsCoreDevice: false, simulatorBooted: booted) + step("installing prepared fixture app") + try await client.install(appAt: prepared.packageURL, on: target) #expect(try await client.apps(target).contains { $0.bundleIdentifier == Self.fixtureBundleID }) let launched = try await client.launch(bundleIdentifier: Self.fixtureBundleID, on: target, terminateExisting: true) #expect(launched.contains(Self.fixtureBundleID)) From e1d35cbb52eb4b6b75e0126cef97f36c0f8237fe Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:03:31 -0700 Subject: [PATCH 02/20] test: make owned cancellation fixture signal responsive A blocking FIFO read can defer the shell TERM trap under full-suite scheduling. Wait on a task-owned child instead, then hold the signal handler at the FIFO barrier and reap the child after release. --- Tests/ToolkitCoreTests/CommandRunnerTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/ToolkitCoreTests/CommandRunnerTests.swift b/Tests/ToolkitCoreTests/CommandRunnerTests.swift index c0506bf..8f18776 100644 --- a/Tests/ToolkitCoreTests/CommandRunnerTests.swift +++ b/Tests/ToolkitCoreTests/CommandRunnerTests.swift @@ -102,9 +102,10 @@ struct CommandRunnerTests { // The child acknowledges SIGTERM but waits for a test-controlled release before exit. // A FIFO makes each assertion depend on a real process state rather than a delay. let script = """ - trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; printf "after release\\n"; exit 0' TERM + trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; kill "$child" 2>/dev/null; wait "$child" 2>/dev/null; printf "after release\\n"; exit 0' TERM + sleep 30 & child=$! printf 'ready:%s\\n' "$$" - IFS= read -r hold < "$1" + wait "$child" exit 1 """ let request = CommandRequest(executable: URL(fileURLWithPath: "/bin/sh"), arguments: ["-c", script, "owned-command", barrier.path], From 681bc3e112cc0ffb4190f2be2f5bde29cc9ad387 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:30:48 -0700 Subject: [PATCH 03/20] fix: keep signature verification off cooperative workers Run asynchronous package preparation and IPA inspection on a dedicated Dispatch queue while preserving strict nested signature validation. Carry cancellation through preparation checkpoints and clean an owned result before reporting late cancellation. Exercise concurrent signed inspection and the pre-cancelled preparation path. --- .../Model/FeatureModels.swift | 5 +- .../ToolkitFeatures/IPA/IPAInspector.swift | 9 +++ .../IPA/PreparedAppPackage.swift | 57 +++++++++++++------ .../IPA/SigningInspection.swift | 29 ++++++++++ Sources/idt/IDT.swift | 2 +- .../AppPackagePreparationTests.swift | 31 +++++++++- .../IPAInspectorTests.swift | 4 +- .../RealSimulatorTests.swift | 2 +- 8 files changed, 112 insertions(+), 27 deletions(-) diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift index 0e7f32e..db3e67c 100644 --- a/App/iOSDeveloperToolkit/Model/FeatureModels.swift +++ b/App/iOSDeveloperToolkit/Model/FeatureModels.swift @@ -176,10 +176,7 @@ final class InstallModel { resumeCleanupIfIdle() } let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path], onStart: { self.inspectionOperation = $0 }) { _ in - let preparation = Task.detached(priority: .userInitiated) { try PreparedAppPackage.prepare(url) } - return try await withTaskCancellationHandler { - try await preparation.value - } onCancel: { preparation.cancel() } + try await PreparedAppPackage.prepareAsync(url) } guard selectionID == generation else { if let result { diff --git a/Sources/ToolkitFeatures/IPA/IPAInspector.swift b/Sources/ToolkitFeatures/IPA/IPAInspector.swift index ee20cd9..0cb3682 100644 --- a/Sources/ToolkitFeatures/IPA/IPAInspector.swift +++ b/Sources/ToolkitFeatures/IPA/IPAInspector.swift @@ -68,8 +68,17 @@ public struct IPAInspection: Sendable, Hashable, Codable { /// Inspects an `.ipa` package locally before any installation is offered. public enum IPAInspector { + /// Suspends the caller while blocking archive and native signature checks finish. Cancellation + /// waits for native validation and scratch cleanup before it is reported. + public static func inspectAsync(_ url: URL) async throws -> IPAInspection { + let inspection = try await PackageInspectionExecutor.run { _ in try inspect(url) } + try Task.checkCancellation() + return inspection + } + public static let maximumInfoPlistBytes: UInt64 = 10 * 1024 * 1024 + /// Blocking entry point for synchronous callers. Swift tasks should use `inspectAsync`. public static func inspect(_ url: URL) throws -> IPAInspection { guard url.pathExtension.lowercased() == "ipa" else { throw ToolkitError.invalidInput("Choose an .ipa package.") diff --git a/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift index 2f35967..03d23f1 100644 --- a/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift +++ b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift @@ -21,19 +21,40 @@ public struct PreparedAppPackage: Sendable { private let directoryInode: ino_t private static let maximumBytes = ZipArchive.maximumTotalUncompressedBytes + /// Suspends during blocking package and signature work. A cancelled caller keeps ownership + /// until native validation finishes, then removes its private copy before receiving cancellation. + public static func prepareAsync(_ source: URL) async throws -> PreparedAppPackage { + let package = try await PackageInspectionExecutor.run { checkCancellation in + try prepare(source, checkCancellation: checkCancellation) + } + guard !Task.isCancelled else { + do { try package.remove() } + catch { + throw ToolkitError.fileSystem("Package inspection was stopped, but its private copy could not be removed.", path: package.directory.path, underlying: error) + } + throw CancellationError() + } + return package + } + + /// Blocking entry point for synchronous callers. Swift tasks should use `prepareAsync`. public static func prepare(_ source: URL) throws -> PreparedAppPackage { - try Task.checkCancellation() + try prepare(source, checkCancellation: { try Task.checkCancellation() }) + } + + private static func prepare(_ source: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> PreparedAppPackage { + try checkCancellation() guard source.isFileURL, ["ipa", "app"].contains(source.pathExtension.lowercased()) else { throw ToolkitError.invalidInput("Choose an .ipa package or an iOS .app bundle.") } let original = source.standardizedFileURL - let identity = try packageIdentity(original) + let identity = try packageIdentity(original, checkCancellation: checkCancellation) let directory = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-prepared-app") let copy = directory.appendingPathComponent(original.lastPathComponent) do { try FileManager.default.copyItem(at: original, to: copy) - try Task.checkCancellation() - guard try packageIdentity(original) == identity, try packageIdentity(copy) == identity else { + try checkCancellation() + guard try packageIdentity(original, checkCancellation: checkCancellation) == identity, try packageIdentity(copy, checkCancellation: checkCancellation) == identity else { throw ToolkitError(.invalidInput, message: "The package changed while it was being prepared.", recovery: "Choose the package again after its build or copy has finished.", technicalDetail: original.path) } let inspection: IPAInspection? @@ -47,13 +68,13 @@ public struct PreparedAppPackage: Sendable { platforms = try validatedPlatforms(inspected.supportedPlatforms) signature = inspected.signature } else { - let info = try bundleInfo(copy) + let info = try bundleInfo(copy, checkCancellation: checkCancellation) inspection = nil appName = info.name platforms = info.platforms signature = CodeSignatureInspector.inspect(bundle: copy) } - guard try packageIdentity(copy) == identity, try packageIdentity(original) == identity else { + guard try packageIdentity(copy, checkCancellation: checkCancellation) == identity, try packageIdentity(original, checkCancellation: checkCancellation) == identity else { throw ToolkitError(.invalidInput, message: "The package changed during inspection.", recovery: "Choose the completed package again.", technicalDetail: original.path) } let ownership = try metadata(directory) @@ -129,9 +150,9 @@ public struct PreparedAppPackage: Sendable { return values } - private static func bundleInfo(_ bundle: URL) throws -> (name: String, platforms: [String]) { + private static func bundleInfo(_ bundle: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> (name: String, platforms: [String]) { let descriptor = try openChild(root: bundle, relativePath: "Info.plist") - let data = try readFile(descriptor, maximumBytes: IPAInspector.maximumInfoPlistBytes) + let data = try readFile(descriptor, maximumBytes: IPAInspector.maximumInfoPlistBytes, checkCancellation: checkCancellation) let info = try PlistValue.decode(data) guard info.dictionaryValue != nil else { throw ToolkitError.invalidInput("The app's Info.plist is not a dictionary.") } func required(_ key: String) throws -> String { @@ -172,13 +193,17 @@ public struct PreparedAppPackage: Sendable { } private static func packageIdentity(_ url: URL) throws -> String { - try Task.checkCancellation() + try packageIdentity(url, checkCancellation: { try Task.checkCancellation() }) + } + + private static func packageIdentity(_ url: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> String { + try checkCancellation() let info = try metadata(url) if url.pathExtension.lowercased() == "ipa" { guard info.st_mode & S_IFMT == S_IFREG else { throw ToolkitError.invalidInput("The IPA package must be a regular file, not a symbolic link.") } let descriptor = open(url.path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) guard descriptor >= 0 else { throw fileError("Could not open the IPA package safely.", url: url) } - return try hashFile(descriptor, url: url, maximumBytes: maximumBytes).hash + return try hashFile(descriptor, url: url, maximumBytes: maximumBytes, checkCancellation: checkCancellation).hash } guard info.st_mode & S_IFMT == S_IFDIR else { throw ToolkitError.invalidInput("The app bundle must be a directory, not a symbolic link.") } let root = url.resolvingSymlinksInPath() @@ -192,7 +217,7 @@ public struct PreparedAppPackage: Sendable { var entries = [TreeEntry(path: "", kind: "directory", permissions: info.st_mode & 0o7777, hash: nil, link: nil)] var totalBytes: UInt64 = 0 for case let enumeratedURL as URL in enumerator { - try Task.checkCancellation() + try checkCancellation() guard entries.count < ZipArchive.maximumEntries else { throw ToolkitError.invalidInput("The app bundle exceeds the package file-count limit.") } // FileManager may return /var URLs for a canonical /private/var root. Resolve only // the parent so a leaf symbolic link remains visible to lstat and the identity. @@ -204,7 +229,7 @@ public struct PreparedAppPackage: Sendable { switch kind { case S_IFREG: let descriptor = try openChild(root: root, relativePath: relativePath) - let file = try hashFile(descriptor, url: child, maximumBytes: maximumBytes - totalBytes) + let file = try hashFile(descriptor, url: child, maximumBytes: maximumBytes - totalBytes, checkCancellation: checkCancellation) totalBytes += file.bytes entries.append(TreeEntry(path: relativePath, kind: "file", permissions: fileMetadata.st_mode & 0o7777, hash: file.hash, link: nil)) case S_IFDIR: @@ -271,7 +296,7 @@ public struct PreparedAppPackage: Sendable { guard close(descriptor) == 0 else { throw fileError("Could not close an app-bundle descriptor.", url: root) } } - private static func hashFile(_ descriptor: Int32, url: URL, maximumBytes: UInt64) throws -> (hash: String, bytes: UInt64) { + private static func hashFile(_ descriptor: Int32, url: URL, maximumBytes: UInt64, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> (hash: String, bytes: UInt64) { try withFileHandle(descriptor) { handle in var info = stat() guard fstat(descriptor, &info) == 0 else { throw fileError("Could not inspect the package file.", url: url) } @@ -281,7 +306,7 @@ public struct PreparedAppPackage: Sendable { var hasher = SHA256() var bytes: UInt64 = 0 while let data = try handle.read(upToCount: 1 << 20), !data.isEmpty { - try Task.checkCancellation() + try checkCancellation() guard UInt64(data.count) <= maximumBytes - bytes else { throw ToolkitError.invalidInput("The package changed size or exceeds the package preparation limit.") } bytes += UInt64(data.count) hasher.update(data: data) @@ -290,7 +315,7 @@ public struct PreparedAppPackage: Sendable { } } - private static func readFile(_ descriptor: Int32, maximumBytes: UInt64) throws -> Data { + private static func readFile(_ descriptor: Int32, maximumBytes: UInt64, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> Data { try withFileHandle(descriptor) { handle in var info = stat() guard fstat(descriptor, &info) == 0 else { @@ -301,7 +326,7 @@ public struct PreparedAppPackage: Sendable { } var data = Data() while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { - try Task.checkCancellation() + try checkCancellation() guard UInt64(chunk.count) <= maximumBytes - UInt64(data.count) else { throw ToolkitError.invalidInput("The app's Info.plist exceeds the 10 MB inspection limit.") } diff --git a/Sources/ToolkitFeatures/IPA/SigningInspection.swift b/Sources/ToolkitFeatures/IPA/SigningInspection.swift index a00966d..b049479 100644 --- a/Sources/ToolkitFeatures/IPA/SigningInspection.swift +++ b/Sources/ToolkitFeatures/IPA/SigningInspection.swift @@ -1,3 +1,4 @@ +import Dispatch import Foundation import Security import ToolkitCore @@ -164,3 +165,31 @@ public enum CodeSignatureInspector { } } } + +/// Security.framework can wait for Dispatch work while validating nested code. Keep package +/// inspection off Swift's cooperative executor and retain its worker until native work finishes. +enum PackageInspectionExecutor { + typealias CancellationCheck = @Sendable () throws -> Void + + private static let queue = DispatchQueue(label: "io.hideouts.package-inspection", qos: .userInitiated) + + static func run(_ operation: @escaping @Sendable (CancellationCheck) throws -> Value) async throws -> Value { + let cancelled = LockedValue(false) + let checkCancellation: CancellationCheck = { + if cancelled.current { throw CancellationError() } + } + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + queue.async { + continuation.resume(with: Result { + try checkCancellation() + return try operation(checkCancellation) + }) + } + } + } onCancel: { + cancelled.withLock { $0 = true } + } + } +} diff --git a/Sources/idt/IDT.swift b/Sources/idt/IDT.swift index 102b51a..916453a 100644 --- a/Sources/idt/IDT.swift +++ b/Sources/idt/IDT.swift @@ -134,7 +134,7 @@ struct InspectIPA: AsyncParsableCommand { func run() async throws { do { - let inspection = try IPAInspector.inspect(URL(fileURLWithPath: (path as NSString).expandingTildeInPath)) + let inspection = try await IPAInspector.inspectAsync(URL(fileURLWithPath: (path as NSString).expandingTildeInPath)) if json { FileHandle.standardOutput.write(try JSONOutput.encode(inspection)) } else { diff --git a/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift index d4b8df1..ecb63e4 100644 --- a/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift +++ b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift @@ -30,6 +30,29 @@ struct AppPackagePreparationTests { #expect(try Data(contentsOf: app.appendingPathComponent("Info.plist")) == originalInfo) } + @Test func cancelledAsyncPreparationDoesNotReturnAnOwnedPackage() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-cancelled") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "Demo", platform: "iPhoneSimulator") + let observedCancellation = try await withThrowingTaskGroup(of: Bool.self) { group in + group.cancelAll() + group.addTask { + do { + let package = try await PreparedAppPackage.prepareAsync(app) + try package.remove() + return false + } catch is CancellationError { + return true + } + } + var cancelled = false + for try await result in group { cancelled = result } + return cancelled + } + #expect(observedCancellation) + #expect(FileManager.default.fileExists(atPath: app.appendingPathComponent("Demo").path)) + } + private enum ChangedLocation: String, CaseIterable { case original, prepared } @@ -67,11 +90,11 @@ struct AppPackagePreparationTests { let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-physical") defer { removeTestItem(root) } let app = try makeApp(root, name: "DeviceDemo", platform: "iPhoneOS") - let unsigned = try PreparedAppPackage.prepare(app) + let unsigned = try await PreparedAppPackage.prepareAsync(app) defer { removeTestPackage(unsigned) } #expect(throws: ToolkitError.self) { try unsigned.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: true) } try await sign(app, in: root) - let signed = try PreparedAppPackage.prepare(app) + let signed = try await PreparedAppPackage.prepareAsync(app) defer { removeTestPackage(signed) } #expect(signed.signature?.status == .valid) try signed.validate(for: target(.physical), supportsCoreDevice: true, simulatorBooted: false) @@ -107,8 +130,10 @@ struct AppPackagePreparationTests { let app = try makeApp(root, name: "Demo", platform: "iPhoneOS") try await sign(app, in: root) let ipa = try makeIPA(app, in: root, name: "Signed.ipa") - let prepared = try PreparedAppPackage.prepare(ipa) + async let inspection = IPAInspector.inspectAsync(ipa) + let prepared = try await PreparedAppPackage.prepareAsync(ipa) defer { removeTestPackage(prepared) } + #expect(try await inspection.isInstallable) #expect(prepared.inspection?.isInstallable == true) try prepared.revalidate() try prepared.validate(for: target(.physical), supportsCoreDevice: false, simulatorBooted: false) diff --git a/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift b/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift index 20a8c84..78133bf 100644 --- a/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift +++ b/Tests/ToolkitFeaturesTests/IPAInspectorTests.swift @@ -135,14 +135,14 @@ struct IPAInspectorTests { #expect(signing.succeeded, "\(signing.standardErrorText)") #expect(try await run("/usr/bin/zip", ["-qr", "signed.ipa", "Payload"], in: directory).succeeded) - let signed = try IPAInspector.inspect(directory.appendingPathComponent("signed.ipa")) + let signed = try await IPAInspector.inspectAsync(directory.appendingPathComponent("signed.ipa")) #expect(signed.signature.status == .valid, "\(signed.signature.detail)") #expect(signed.signature.identifier == "com.example.demo") #expect(signed.isInstallable) try Data("tampered".utf8).write(to: app.appendingPathComponent("resource.txt")) #expect(try await run("/usr/bin/zip", ["-qr", "tampered.ipa", "Payload"], in: directory).succeeded) - let tampered = try IPAInspector.inspect(directory.appendingPathComponent("tampered.ipa")) + let tampered = try await IPAInspector.inspectAsync(directory.appendingPathComponent("tampered.ipa")) #expect(tampered.signature.status == .invalid) #expect(!tampered.isInstallable) } diff --git a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift index bc8e27f..e71e430 100644 --- a/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift +++ b/Tests/ToolkitFeaturesTests/RealSimulatorTests.swift @@ -112,7 +112,7 @@ struct RealSimulatorTests { // Install, list, launch, and remove a real (minimal) simulator app. step("building fixture app") let fixture = try await Self.buildFixtureApp(in: directory) - let prepared = try PreparedAppPackage.prepare(fixture) + let prepared = try await PreparedAppPackage.prepareAsync(fixture) defer { do { try prepared.remove() } catch { Issue.record("Could not remove the prepared simulator fixture: \(error.localizedDescription)") } From b696d10aa4dee66d07da95656b7920b971e118a9 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:14:13 -0800 Subject: [PATCH 04/20] hideouts/package-inspection: prepare hosted native acceptance --- .github/workflows/ci.yml | 9 + App/UITests/PackageInspectionUITests.swift | 52 +++ .../Components/Components.swift | 14 +- App/iOSDeveloperToolkit/Model/AppModel.swift | 62 ++- App/iOSDeveloperToolkit/Views/AppsViews.swift | 18 + .../Views/ContentView.swift | 1 + iOSDeveloperToolkit.xcodeproj/project.pbxproj | 4 + scripts/PackageInspectionFixtures.swift | 373 ++++++++++++++++++ 8 files changed, 527 insertions(+), 6 deletions(-) create mode 100644 App/UITests/PackageInspectionUITests.swift create mode 100644 scripts/PackageInspectionFixtures.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f2ee08..bf05ce2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,14 @@ jobs: -configuration Debug -destination 'platform=macOS' \ -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/UITests.xcresult" \ test-without-building + - name: Observe native package file panels + env: + TEST_RUNNER_IDT_PACKAGE_UI_TESTS: "1" + run: | + xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -configuration Debug -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/PackageUITests.xcresult" \ + -only-testing:iOSDeveloperToolkitUITests/PackageInspectionUITests test-without-building - name: Render every page at the default and minimum window sizes run: | scripts/check-layout.sh \ @@ -98,6 +106,7 @@ jobs: name: ui-test-results path: | ${{ runner.temp }}/UITests.xcresult + ${{ runner.temp }}/PackageUITests.xcresult ${{ runner.temp }}/layout if-no-files-found: ignore diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift new file mode 100644 index 0000000..63fd28f --- /dev/null +++ b/App/UITests/PackageInspectionUITests.swift @@ -0,0 +1,52 @@ +import XCTest + +/// Explicit hosted acceptance keeps native file panels and termination out of ordinary runs. +@MainActor +final class PackageInspectionUITests: XCTestCase { + private var application: XCUIApplication? + + override func setUp() async throws { + continueAfterFailure = false + try XCTSkipUnless(ProcessInfo.processInfo.environment["IDT_PACKAGE_UI_TESTS"] == "1", + "Requires the explicit hosted package inspection acceptance opt-in.") + } + + override func tearDown() async throws { + application?.terminate() + } + + func testPackageInspectionRecoveryAndNormalQuitCleanup() throws { + let app = XCUIApplication() + application = app + app.launchArguments = ["-ui-testing", "YES", "-demo-mode", "NO", "-workspace", "installApp", + "-ApplePersistenceIgnoreState", "YES"] + app.launchEnvironment["IDT_RESTORE_HELPERS"] = "" + app.launch() + app.activate() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) + let choose = app.buttons["choose-package"] + XCTAssertTrue(choose.waitForExistence(timeout: 10)) + XCTAssertTrue(choose.isHittable) + choose.click() + let panel = app.descendants(matching: .any)["file-open-panel"] + XCTAssertTrue(panel.waitForExistence(timeout: 10)) + attach(app.debugDescription, name: "package-native-open-panel") + let fields = app.textFields.count + panel.typeKey("g", modifierFlags: [.command, .shift]) + let fieldAppeared = app.textFields.element(boundBy: fields).waitForExistence(timeout: 5) + attach(app.debugDescription, name: "package-native-go-to-panel") + XCTAssertTrue(fieldAppeared) + app.typeKey(.escape, modifierFlags: []) + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(panel.waitForNonExistence(timeout: 5)) + app.typeKey("q", modifierFlags: [.command]) + XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "Normal Command-Q must exit the test app") + } + + private func attach(_ text: String, name: String) { + let attachment = XCTAttachment(string: String(text.prefix(120_000))) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/App/iOSDeveloperToolkit/Components/Components.swift b/App/iOSDeveloperToolkit/Components/Components.swift index 0853fa6..450ae4d 100644 --- a/App/iOSDeveloperToolkit/Components/Components.swift +++ b/App/iOSDeveloperToolkit/Components/Components.swift @@ -238,12 +238,15 @@ struct ConfirmationSheet: View { VStack(alignment: .leading, spacing: 14) { HStack { Text(title).font(.title2.bold()) + .accessibilityIdentifier("confirmation-title") Spacer() RiskBadge(risk: requirement.risk) } Text(detail).fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("confirmation-detail") if let target { InfoRow("Target", "\(target.name) — \(target.kind.label)") + .accessibilityIdentifier("confirmation-target") InfoRow("UDID", target.udid, monospaced: true) } if let commandPreview { @@ -272,6 +275,7 @@ struct ConfirmationSheet: View { Spacer() Button("Cancel", role: .cancel) { dismiss() } .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("confirmation-cancel") Button(requirement.risk == .readOnly ? "Run" : "Continue") { dismiss() onConfirm() @@ -297,8 +301,10 @@ struct ErrorSheet: View { .font(.title2.bold()) .foregroundStyle(.orange) Text(error.message).font(.body).fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("error-message") if let recovery = error.recovery { Text(recovery).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("error-recovery") } if let details = error.details, !details.isEmpty { DisclosureGroup("Technical details") { @@ -316,11 +322,11 @@ struct ErrorSheet: View { HStack { Spacer() Button("OK") { dismiss() }.keyboardShortcut(.defaultAction) + .accessibilityIdentifier("error-dismiss") } } .padding(20) .frame(width: 520) - .accessibilityIdentifier("error-sheet") } } @@ -347,6 +353,12 @@ enum FilePanels { static func chooseFile(title: String, allowedExtensions: [String], directory: URL? = nil) -> URL? { let panel = NSOpenPanel() + panel.identifier = NSUserInterfaceItemIdentifier("file-open-panel") + panel.setAccessibilityIdentifier("file-open-panel") + if let confirmView = panel.defaultButtonCell?.controlView { + confirmView.identifier = NSUserInterfaceItemIdentifier("file-open-confirm") + confirmView.setAccessibilityIdentifier("file-open-confirm") + } panel.title = title panel.canChooseFiles = true panel.canChooseDirectories = allowedExtensions.contains("app") diff --git a/App/iOSDeveloperToolkit/Model/AppModel.swift b/App/iOSDeveloperToolkit/Model/AppModel.swift index b842b3c..8a47c41 100644 --- a/App/iOSDeveloperToolkit/Model/AppModel.swift +++ b/App/iOSDeveloperToolkit/Model/AppModel.swift @@ -6,6 +6,32 @@ import SwiftUI import ToolkitCore import ToolkitFeatures +#if DEBUG +/// Restricts package acceptance to the simulator created by its owning fixture run. +struct PackageAcceptanceTarget: Sendable { + let simulator: String + let owner: String + + static func parse(arguments: [String], uiTesting: Bool, demoMode: Bool) throws(ToolkitError) -> PackageAcceptanceTarget? { + let simulatorFlags = arguments.indices.filter { arguments[$0] == "-package-acceptance-simulator" } + let ownerFlags = arguments.indices.filter { arguments[$0] == "-package-acceptance-owner" } + guard !simulatorFlags.isEmpty || !ownerFlags.isEmpty else { return nil } + guard uiTesting, !demoMode, simulatorFlags.count == 1, ownerFlags.count == 1, + let simulatorIndex = simulatorFlags.first, arguments.indices.contains(simulatorIndex + 1), + let ownerIndex = ownerFlags.first, arguments.indices.contains(ownerIndex + 1), + let simulator = UUID(uuidString: arguments[simulatorIndex + 1]), + let owner = UUID(uuidString: arguments[ownerIndex + 1]) else { + throw ToolkitError(.invalidInput, message: "Package acceptance requires a simulator UUID and an owner UUID.", recovery: "Use a Debug build with -ui-testing YES, -demo-mode NO and one value for each package acceptance target and owner.", technicalDetail: nil) + } + return PackageAcceptanceTarget(simulator: simulator.uuidString, owner: owner.uuidString) + } + + func contains(_ device: Device) -> Bool { + device.kind == .simulator && device.udid.uppercased() == simulator && device.name == "IDT Package \(owner)" + } +} +#endif + /// An error ready for an alert: plain message first, technical details on request. struct PresentedError: Identifiable, Equatable { let id = UUID() @@ -68,6 +94,9 @@ final class AppModel { let runner: OwnedCommandRunning = ProcessCommandRunner() let journal = OperationJournal() let discovery: DeviceDiscovery +#if DEBUG + let packageAcceptanceTarget: PackageAcceptanceTarget? +#endif /// A fresh executor carries the current developer-image folders. var executor: ActionExecutor { ActionExecutor(runner: runner, developerImageFolders: developerImage.folders) } let logger = ToolkitLog.application @@ -131,11 +160,28 @@ final class AppModel { } let uiTesting = flag("-ui-testing") ?? false let forceDemo = flag("-demo-mode") ?? false - demoMode = forceDemo || (!uiTesting && UserDefaults.standard.bool(forKey: "demoMode")) + let isDemoMode = forceDemo || (!uiTesting && UserDefaults.standard.bool(forKey: "demoMode")) + demoMode = isDemoMode selectedDeviceID = forceDemo ? DemoMode.device.id : UserDefaults.standard.string(forKey: "selectedDeviceID") - // UI tests run without touching real discovery sources. - let configuration: DeviceDiscovery.Configuration = uiTesting - ? .init(usbmux: USBMuxClient(socketPath: "/nonexistent/ui-testing-usbmuxd"), coreDevice: nil, simulators: nil, enrichWithLockdown: false) + let acceptanceRequested = arguments.contains("-package-acceptance-simulator") || arguments.contains("-package-acceptance-owner") + let acceptanceError: ToolkitError? + let acceptanceSimulators: SimulatorClient? +#if DEBUG + do { + packageAcceptanceTarget = try PackageAcceptanceTarget.parse(arguments: arguments, uiTesting: uiTesting, demoMode: isDemoMode) + acceptanceError = nil + } catch { + packageAcceptanceTarget = nil + acceptanceError = error + } + acceptanceSimulators = packageAcceptanceTarget == nil ? nil : SimulatorClient(runner: runner) +#else + acceptanceSimulators = nil + acceptanceError = acceptanceRequested ? ToolkitError(.unsupported, message: "Package acceptance requires a Debug build.", recovery: "Build the Debug configuration and rerun the native acceptance test.", technicalDetail: nil) : nil +#endif + // UI tests exclude physical discovery; only explicit package acceptance enables simulators. + let configuration: DeviceDiscovery.Configuration = uiTesting || acceptanceRequested + ? .init(usbmux: USBMuxClient(socketPath: "/nonexistent/ui-testing-usbmuxd"), coreDevice: nil, simulators: acceptanceSimulators, enrichWithLockdown: false) : .init() discovery = DeviceDiscovery(configuration: configuration) developerImage = DeveloperImageModel() @@ -151,6 +197,7 @@ final class AppModel { if let destination = arguments.firstIndex(of: "-workspace").flatMap({ arguments.indices.contains($0 + 1) ? arguments[$0 + 1] : nil }).flatMap(Workspace.init(rawValue:)) { workspace = destination } + if let acceptanceError { presentedError = PresentedError(acceptanceError) } } // MARK: Lifecycle @@ -217,7 +264,12 @@ final class AppModel { // MARK: Devices var allDevices: [Device] { - demoMode ? [DemoMode.device] + snapshot.devices : snapshot.devices +#if DEBUG + if let packageAcceptanceTarget { + return snapshot.devices.filter { packageAcceptanceTarget.contains($0) } + } +#endif + return demoMode ? [DemoMode.device] + snapshot.devices : snapshot.devices } var physicalDevices: [Device] { allDevices.filter { $0.kind == .physical || $0.kind == .demo } } diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index 4987540..4a55af0 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -114,9 +114,27 @@ struct InstallAppView: View { .accessibilityIdentifier("choose-package") if let url = install.packageURL { Text(url.lastPathComponent).font(.callout.monospaced()).lineLimit(1).truncationMode(.middle) + .accessibilityIdentifier("package-selection") } } } +#if DEBUG + if model.packageAcceptanceTarget != nil { + VStack(alignment: .leading, spacing: 2) { + Text(install.preparedPackage?.directory.path ?? "none") + .accessibilityIdentifier("package-prepared-directory") + Text(install.preparedPackage?.id.uuidString ?? "none") + .accessibilityIdentifier("package-prepared-id") + Text(install.preparedPackage?.originalURL.path ?? "none") + .accessibilityIdentifier("package-original-url") + Text(String(model.operations.count)) + .accessibilityIdentifier("package-active-operations") + } + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + } +#endif if install.isInspecting { ProgressView("Inspecting the selected package…") .accessibilityIdentifier("package-inspection-running") diff --git a/App/iOSDeveloperToolkit/Views/ContentView.swift b/App/iOSDeveloperToolkit/Views/ContentView.swift index 903ad9c..01c7410 100644 --- a/App/iOSDeveloperToolkit/Views/ContentView.swift +++ b/App/iOSDeveloperToolkit/Views/ContentView.swift @@ -289,6 +289,7 @@ struct TargetPicker: View { } label: { Label("\(device.name) — \(device.displayVersion)\(device.simulatorState == .booted ? " (running)" : "")", systemImage: "\(device.family.symbolName)") } + .accessibilityIdentifier("target-simulator-\(device.target.udid)") } } if model.selectedDeviceID != nil { diff --git a/iOSDeveloperToolkit.xcodeproj/project.pbxproj b/iOSDeveloperToolkit.xcodeproj/project.pbxproj index 0880dad..a94e200 100644 --- a/iOSDeveloperToolkit.xcodeproj/project.pbxproj +++ b/iOSDeveloperToolkit.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 1BEAAB3DA748A2FF696A37BE /* LiveLogsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1BCA4BFD074C07318C65CC /* LiveLogsModel.swift */; }; 244023633FC5B7270C45C371 /* ActionsViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64CADCDAC59205AFA532EF87 /* ActionsViews.swift */; }; 25D059CF99AA7F74E66147F4 /* SmokeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2095C064BD8BE67389318E84 /* SmokeUITests.swift */; }; + 25EFD518CF902622809873B1 /* PackageInspectionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AAA035D99201791C82AA906 /* PackageInspectionUITests.swift */; }; 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 */; }; @@ -58,6 +59,7 @@ /* Begin PBXFileReference section */ 117839C38E19BA74C82D8382 /* ReferenceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceViews.swift; sourceTree = ""; }; 192082EDB2AA8A31B4DE9A43 /* DeviceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceViews.swift; sourceTree = ""; }; + 1AAA035D99201791C82AA906 /* PackageInspectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageInspectionUITests.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 = ""; }; 3DDC0E48182D2C63DFB57618 /* SecurityAnalysisModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityAnalysisModel.swift; sourceTree = ""; }; @@ -139,6 +141,7 @@ 60C4D44F4E03EAEE6FC8028C /* UITests */ = { isa = PBXGroup; children = ( + 1AAA035D99201791C82AA906 /* PackageInspectionUITests.swift */, 2095C064BD8BE67389318E84 /* SmokeUITests.swift */, ); path = UITests; @@ -433,6 +436,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 25EFD518CF902622809873B1 /* PackageInspectionUITests.swift in Sources */, 25D059CF99AA7F74E66147F4 /* SmokeUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/scripts/PackageInspectionFixtures.swift b/scripts/PackageInspectionFixtures.swift new file mode 100644 index 0000000..f363645 --- /dev/null +++ b/scripts/PackageInspectionFixtures.swift @@ -0,0 +1,373 @@ +import Foundation +import ToolkitCore + +/// Provisions only this hosted run's simulator and immutable package inputs. Native UI tests +/// choose those files themselves; this host verifies their hashes, inventory and post-Quit cleanup. +@main +struct PackageInspectionFixtures { + struct SourceDigest: Codable { + let path: String + let sha256: String + } + + struct Manifest: Codable { + let owner: String + let simulator: String? + let sources: [SourceDigest]? + let inventory: [String]? + let existingPreparations: [String] + + enum CodingKeys: String, CodingKey { case owner, simulator, sources, inventory, existingPreparations } + + func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(owner, forKey: .owner) + try values.encode(simulator, forKey: .simulator) + try values.encode(sources, forKey: .sources) + try values.encode(inventory, forKey: .inventory) + try values.encode(existingPreparations, forKey: .existingPreparations) + } + } + + struct Simulator { + let identifier: String + let name: String + let state: String + let type: String + let runtime: String + let available: Bool + } + + struct CleanupReceipt: Decodable { + let preparedDirectories: [String] + } + + static let validName = "ValidSimulator.app" + static let invalidName = "Malformed.ipa" + static let fixtureBundle = "io.hideouts.idt.package.fixture" + + static func main() async throws { + let arguments = CommandLine.arguments + guard arguments.count >= 3, arguments.count <= 4 else { + throw failure("Use package-fixtures prepare DIRECTORY, verify-cleanup DIRECTORY RECEIPT_JSON, or cleanup DIRECTORY.", cause: nil) + } + let directory = URL(fileURLWithPath: arguments[2], isDirectory: true).standardizedFileURL + guard let temporary = ProcessInfo.processInfo.environment["RUNNER_TEMP"], + directory.resolvingSymlinksInPath().path.hasPrefix(URL(fileURLWithPath: temporary).resolvingSymlinksInPath().path + "/") else { + throw failure("The fixture directory must be a dedicated child of RUNNER_TEMP.", cause: nil) + } + let runner = ProcessCommandRunner() + switch arguments[1] { + case "prepare" where arguments.count == 3: + try await prepare(directory: directory, runner: runner) + case "verify-cleanup" where arguments.count == 4: + try await verifyCleanup(directory: directory, receipt: URL(fileURLWithPath: arguments[3]), runner: runner) + case "cleanup" where arguments.count == 3: + try await cleanup(directory: directory, runner: runner) + default: + throw failure("The fixture command or its argument count is invalid.", cause: nil) + } + } + + static func prepare(directory: URL, runner: ProcessCommandRunner) async throws { + try SecureFileIO.createNewPrivateDirectory(at: directory) + let owner = UUID().uuidString + let existingPreparations = try FileManager.default.contentsOfDirectory(atPath: NSTemporaryDirectory()) + .filter { $0.hasPrefix("idt-prepared-app.") }.sorted() + var manifest = Manifest(owner: owner, simulator: nil, sources: nil, inventory: nil, existingPreparations: existingPreparations) + try save(manifest, directory: directory) + let template = try await simulatorTemplate(runner: runner) + let result = try await command(["simctl", "create", "IDT Package \(owner)", template.type, template.runtime], timeout: 120, runner: runner) + guard let identifier = UUID(uuidString: result.standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines))?.uuidString else { + throw failure("simctl create did not return a valid fixture UUID; cleanup must reconcile its exact owner name before retrying.", cause: nil) + } + manifest = Manifest(owner: owner, simulator: identifier, sources: nil, inventory: nil, existingPreparations: existingPreparations) + try await register(manifest, directory: directory, runner: runner) + _ = try await command(["simctl", "bootstatus", identifier, "-b"], timeout: 600, runner: runner) + try await requireOwnedSimulator(manifest, runner: runner) + let app = try await fixture(directory: directory, runner: runner) + let malformed = directory.appendingPathComponent(invalidName) + try SecureFileIO.writeNewFile(Data("This is deliberately not a ZIP archive.\n".utf8), to: malformed, mode: 0o600) + let sources = try [validName, invalidName].map { name in + SourceDigest(path: name, sha256: try digest(directory.appendingPathComponent(name))) + } + let inventory = try await inventory(identifier: identifier, runner: runner) + guard !inventory.contains(fixtureBundle) else { + throw failure("The new simulator already contains the acceptance package; installation cancellation cannot be measured.", cause: nil) + } + manifest = Manifest(owner: owner, simulator: identifier, sources: sources, inventory: inventory, existingPreparations: existingPreparations) + try save(manifest, directory: directory) + let environment = [ + "IDT_PACKAGE_FIXTURE_DIRECTORY": directory.path, + "IDT_PACKAGE_FIXTURE_SIMULATOR": identifier, + "IDT_PACKAGE_FIXTURE_OWNER": owner, + "IDT_PACKAGE_FIXTURE_VALID": app.path, + "IDT_PACKAGE_FIXTURE_INVALID": malformed.path, + ] + guard environment.values.allSatisfy({ !$0.contains("\n") && !$0.contains("\r") }) else { + throw failure("Fixture paths cannot contain newline characters in the hosted environment file.", cause: nil) + } + let lines = environment.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)\n" }.joined() + try SecureFileIO.writeNewFile(Data(lines.utf8), to: directory.appendingPathComponent("environment.env"), mode: 0o600) + print("[package fixtures] One owner-matched booted simulator, a genuine signed simulator app, malformed IPA and source/inventory baselines are ready.") + } + + static func verifyCleanup(directory: URL, receipt: URL, runner: ProcessCommandRunner) async throws { + let manifest = try load(directory: directory) + guard let sources = manifest.sources, sources.count == 2, + Set(sources.map(\.path)) == Set([validName, invalidName]), + let expectedInventory = manifest.inventory, let identifier = manifest.simulator else { + throw failure("Package acceptance verification requires a completely prepared ownership registry.", cause: nil) + } + try await requireOwnedSimulator(manifest, runner: runner) + for source in sources { + guard source.sha256 == (try digest(directory.appendingPathComponent(source.path))) else { + throw failure("An original acceptance package changed during inspection, cancellation or normal Quit.", cause: nil) + } + } + guard try await inventory(identifier: identifier, runner: runner) == expectedInventory else { + throw failure("The owned simulator's bundle-identifier inventory changed despite cancelling installation.", cause: nil) + } + let receiptData = try readOwnedFile(receipt, maximumBytes: 16384) + guard let object = try JSONValue.parse(receiptData).object, Set(object.keys) == Set(["preparedDirectories"]) else { + throw failure("The native cleanup receipt has an invalid schema.", cause: nil) + } + let recorded = try JSONDecoder().decode(CleanupReceipt.self, from: receiptData).preparedDirectories + guard recorded.count == 2, Set(recorded).count == 2 else { + throw failure("The native receipt must contain distinct actual preparation paths from the first valid choice and recovered valid choice.", cause: nil) + } + let temporary = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).resolvingSymlinksInPath() + for path in recorded { + let prepared = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + let name = prepared.lastPathComponent + let suffix = String(name.dropFirst("idt-prepared-app.".count)) + guard path.hasPrefix("/"), prepared.deletingLastPathComponent().resolvingSymlinksInPath() == temporary, + name.hasPrefix("idt-prepared-app."), suffix.count == 8, + suffix.utf8.allSatisfy({ (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) }), + !manifest.existingPreparations.contains(name) else { + throw failure("The native receipt contains a path outside this run's possible new private preparations.", cause: nil) + } + var metadata = stat() + guard lstat(prepared.path, &metadata) != 0 else { + throw failure("A recorded private preparation remains after malformed re-selection or normal Quit.", cause: nil) + } + guard errno == ENOENT else { + throw failure("A recorded private preparation could not be checked for absence.", cause: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) + } + } + print("[package fixtures] Independent verification passed: both recorded private preparations are absent, source hashes are unchanged and the owned simulator inventory is unchanged.") + } + + static func cleanup(directory: URL, runner: ProcessCommandRunner) async throws { + let manifest = try load(directory: directory) + let name = "IDT Package \(manifest.owner)" + let records = try await simulators(runner: runner) + let named = records.filter { $0.name == name } + guard named.count <= 1 else { throw failure("Multiple simulators have the exact fixture owner name; cleanup was refused.", cause: nil) } + let record: Simulator? + if let identifier = manifest.simulator { + record = records.first(where: { $0.identifier == identifier }) + guard record == nil ? named.isEmpty : record?.name == name else { + throw failure("The registered simulator UUID and its exact owner name no longer identify the same target; cleanup was refused.", cause: nil) + } + } else { + // A create response or registry write can fail after allocation. Reconcile one exact + // owner name; creation is never blindly retried and other simulators are untouched. + record = named.first + } + if let record { + if record.state == "Booted" { + _ = try await command(["simctl", "shutdown", record.identifier], timeout: 120, runner: runner) + } + _ = try await command(["simctl", "delete", record.identifier], timeout: 120, runner: runner) + } + guard try await !simulators(runner: runner).contains(where: { $0.identifier == manifest.simulator || $0.name == name }) else { + throw failure("Fresh discovery still contains the fixture UUID or exact owner name; the registry was retained.", cause: nil) + } + try FileManager.default.removeItem(at: directory) + print("[package fixtures] Fresh discovery verified the owned simulator UUID and exact owner name absent; the owned fixture directory was removed.") + } + + static func fixture(directory: URL, runner: ProcessCommandRunner) async throws -> URL { + let app = directory.appendingPathComponent(validName, isDirectory: true) + try SecureFileIO.createNewPrivateDirectory(at: app) + let source = directory.appendingPathComponent("Fixture.c") + try SecureFileIO.writeNewFile(Data("int main(void) { return 0; }\n".utf8), to: source, mode: 0o600) + #if arch(arm64) + let triple = "arm64-apple-ios17.0-simulator" + #else + let triple = "x86_64-apple-ios17.0-simulator" + #endif + _ = try await command(["--sdk", "iphonesimulator", "clang", "-target", triple, "-o", app.appendingPathComponent("Fixture").path, source.path], timeout: 300, runner: runner) + let info: PlistValue = ["CFBundleIdentifier": .string(fixtureBundle), "CFBundleExecutable": "Fixture", "CFBundleName": "Package Fixture", + "CFBundleDisplayName": "Package Fixture", "CFBundlePackageType": "APPL", "CFBundleVersion": "1", + "CFBundleShortVersionString": "1.0", "MinimumOSVersion": "17.0", "CFBundleSupportedPlatforms": ["iPhoneSimulator"], "UIDeviceFamily": [1, 2]] + try SecureFileIO.writeNewFile(info.encoded(format: .xml), to: app.appendingPathComponent("Info.plist"), mode: 0o600) + let request = CommandRequest(executable: try AppleTool.codesign.locate(), arguments: ["--sign", "-", app.path], timeout: 60, displayName: "sign package fixture") + let signed = try await runner.run(request) + guard signed.succeeded else { throw failure("Signing the genuine simulator package failed.", cause: nil).appendingDetail(String(signed.technicalSummary.prefix(1536))) } + return app + } + + /// Includes each relative entry name, kind, mode and file digest; original package metadata + /// and nested bytes must survive inspection and cancellation, rather than only its root file. + static func digest(_ source: URL) throws -> String { + var paths = [source] + var metadata = stat() + var lines: [String] = [] + var index = 0 + while index < paths.count { + let path = paths[index] + index += 1 + guard lstat(path.path, &metadata) == 0, metadata.st_uid == getuid() else { + throw failure("An original package entry cannot be verified for hashing.", cause: nil) + } + let kind = metadata.st_mode & S_IFMT + guard kind == S_IFREG || kind == S_IFDIR else { throw failure("Generated package inputs must contain only owned regular files and directories.", cause: nil) } + if kind == S_IFDIR { + paths.append(contentsOf: try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil).sorted(by: { $0.path < $1.path })) + guard paths.count <= 4096 else { throw failure("The generated package exceeds the acceptance fixture entry bound.", cause: nil) } + } + let relative = String(path.path.dropFirst(source.path.count)) + let content = kind == S_IFREG ? try SecureFileIO.sha256(of: path) : "directory" + lines.append("\(relative)\u{0}\(kind)\u{0}\(metadata.st_mode & 0o777)\u{0}\(content)\n") + } + return SecureFileIO.sha256(of: Data(lines.sorted().joined().utf8)) + } + + static func inventory(identifier: String, runner: ProcessCommandRunner) async throws -> [String] { + let result = try await command(["simctl", "listapps", identifier], timeout: 60, runner: runner) + guard let applications = try PlistValue.decode(result.standardOutput).dictionaryValue, + applications.count <= 4096, applications.keys.allSatisfy({ !$0.isEmpty && $0.utf8.count <= 512 }) else { + throw failure("The owned simulator returned an invalid application inventory.", cause: nil) + } + return applications.keys.sorted() + } + + static func simulatorTemplate(runner: ProcessCommandRunner) async throws -> Simulator { + let runtimeJSON = try JSONValue.parse(try await command(["simctl", "list", "runtimes", "--json"], timeout: 60, runner: runner).standardOutput) + let typeJSON = try JSONValue.parse(try await command(["simctl", "list", "devicetypes", "--json"], timeout: 60, runner: runner).standardOutput) + guard let runtimes = runtimeJSON["runtimes"]?.array, let types = typeJSON["devicetypes"]?.array else { + throw failure("simctl did not return runtime and device-type arrays.", cause: nil) + } + let availableRuntimes = try runtimes.filter { try boolean($0, field: "isAvailable") }.map { try string($0, field: "identifier") } + let iphoneTypes = try types.filter { try string($0, field: "name").hasPrefix("iPhone") }.map { try string($0, field: "identifier") } + guard let template = try await simulators(runner: runner).first(where: { + $0.available && availableRuntimes.contains($0.runtime) && iphoneTypes.contains($0.type) + && $0.runtime.hasPrefix("com.apple.CoreSimulator.SimRuntime.iOS-") + && $0.type.hasPrefix("com.apple.CoreSimulator.SimDeviceType.iPhone-") + }) else { + throw failure("The hosted runner needs an available iPhone simulator runtime and device type.", cause: nil) + } + return template + } + + static func simulators(runner: ProcessCommandRunner) async throws -> [Simulator] { + let json = try JSONValue.parse(try await command(["simctl", "list", "devices", "--json"], timeout: 60, runner: runner).standardOutput) + guard let groups = json["devices"]?.object else { throw failure("simctl returned no device groups.", cause: nil) } + return try groups.keys.sorted().flatMap { runtime -> [Simulator] in + guard let devices = groups[runtime]?.array else { throw failure("simctl returned a non-array device group.", cause: nil) } + return try devices.map { value in + guard let identifier = UUID(uuidString: try string(value, field: "udid"))?.uuidString else { throw failure("simctl returned an invalid UUID.", cause: nil) } + return Simulator(identifier: identifier, name: try string(value, field: "name"), state: try string(value, field: "state"), + type: try string(value, field: "deviceTypeIdentifier"), runtime: runtime, available: try boolean(value, field: "isAvailable")) + } + } + } + + static func requireOwnedSimulator(_ manifest: Manifest, runner: ProcessCommandRunner) async throws { + guard let identifier = manifest.simulator, + try await simulators(runner: runner).contains(where: { + $0.identifier == identifier && $0.name == "IDT Package \(manifest.owner)" && $0.available && $0.state == "Booted" + }) else { throw failure("Acceptance requires this run's booted simulator with both registered UUID and exact owner name.", cause: nil) } + } + + static func save(_ manifest: Manifest, directory: URL) throws { + try SecureFileIO.writeAtomically(JSONEncoder().encode(manifest), to: directory.appendingPathComponent("manifest.json"), mode: 0o600) + } + + static func register(_ manifest: Manifest, directory: URL, runner: ProcessCommandRunner) async throws { + do { try save(manifest, directory: directory) } + catch { + let registrationError = error + do { + guard let identifier = manifest.simulator else { throw failure("No newly allocated simulator was recorded for registration cleanup.", cause: nil) } + if let record = try await simulators(runner: runner).first(where: { $0.identifier == identifier }) { + guard record.name == "IDT Package \(manifest.owner)" else { throw failure("The newly allocated simulator changed owner name; immediate cleanup was refused.", cause: nil) } + _ = try await command(["simctl", "delete", identifier], timeout: 120, runner: runner) + } + guard try await !simulators(runner: runner).contains(where: { $0.identifier == identifier || $0.name == "IDT Package \(manifest.owner)" }) else { + throw failure("The unregistered simulator remains after deletion.", cause: nil) + } + } catch { + throw failure("Fixture registration and immediate owned cleanup both failed; the earlier registry was retained.", cause: registrationError).appendingDetail(bounded(error)) + } + throw failure("Fixture registration failed; its newly allocated simulator was removed and the earlier registry retained.", cause: registrationError) + } + } + + static func load(directory: URL) throws -> Manifest { + var metadata = stat() + guard lstat(directory.path, &metadata) == 0, metadata.st_mode & S_IFMT == S_IFDIR, + metadata.st_uid == getuid(), metadata.st_mode & 0o777 == 0o700 else { + throw failure("Cleanup requires the original owner-only fixture directory without symbolic links.", cause: nil) + } + let data = try readOwnedFile(directory.appendingPathComponent("manifest.json"), maximumBytes: 65536) + guard let object = try JSONValue.parse(data).object, + Set(object.keys) == Set(["owner", "simulator", "sources", "inventory", "existingPreparations"]) else { + throw failure("The ownership registry has an invalid schema.", cause: nil) + } + let manifest = try JSONDecoder().decode(Manifest.self, from: data) + let validSimulator = manifest.simulator.map { UUID(uuidString: $0)?.uuidString == $0 } ?? true + guard UUID(uuidString: manifest.owner)?.uuidString == manifest.owner, validSimulator, + manifest.existingPreparations.count <= 4096, + manifest.existingPreparations.allSatisfy({ $0.hasPrefix("idt-prepared-app.") && !$0.contains("/") }) else { + throw failure("The ownership registry contains invalid target or preparation records.", cause: nil) + } + return manifest + } + + static func readOwnedFile(_ url: URL, maximumBytes: Int) throws -> Data { + let descriptor = open(url.path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { throw failure("An acceptance registry or receipt is missing or unreadable.", cause: nil) } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + var metadata = stat() + do { + guard fstat(descriptor, &metadata) == 0, metadata.st_mode & S_IFMT == S_IFREG, + metadata.st_uid == getuid(), metadata.st_size > 0, metadata.st_size <= maximumBytes else { + throw failure("Acceptance registries and receipts must be bounded owner-owned regular files.", cause: nil) + } + let data = try handle.read(upToCount: maximumBytes + 1) ?? Data() + try handle.close() + guard data.count <= maximumBytes else { throw failure("An acceptance registry or receipt grew beyond its size bound.", cause: nil) } + return data + } catch { + do { try handle.close() } + catch let closeError { throw failure("Reading and closing an acceptance record failed.", cause: error).appendingDetail(bounded(closeError)) } + throw error + } + } + + static func command(_ arguments: [String], timeout: TimeInterval, runner: ProcessCommandRunner) async throws -> CommandResult { + let request = CommandRequest(executable: try AppleTool.xcrun.locate(), arguments: arguments, timeout: timeout, displayName: "package fixture tool") + let result = try await runner.run(request) + guard result.succeeded else { throw failure("A package fixture tool failed.", cause: nil).appendingDetail(String(result.technicalSummary.prefix(1536))) } + return result + } + + static func string(_ value: JSONValue, field: String) throws -> String { + guard case .string(let text) = value[field], !text.isEmpty, text.utf8.count <= 512 else { throw failure("simctl returned an invalid \(field) string.", cause: nil) } + return text + } + + static func boolean(_ value: JSONValue, field: String) throws -> Bool { + guard case .bool(let result) = value[field] else { throw failure("simctl returned an invalid \(field) boolean.", cause: nil) } + return result + } + + static func bounded(_ error: Error) -> String { String(String(describing: error).prefix(1536)) } + + static func failure(_ message: String, cause: Error?) -> ToolkitError { + ToolkitError(.commandFailed, message: message, recovery: "Inspect the hosted fixture step and retained ownership registry before retrying.", technicalDetail: cause.map(bounded)) + } +} From 2a640ed8ff68b4163896f688d13a5e3e8f5d63c5 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:30:48 -0800 Subject: [PATCH 05/20] hideouts/package-inspection: use observed native panel identifiers --- .github/workflows/ci.yml | 19 +++++++++++++------ App/UITests/PackageInspectionUITests.swift | 3 ++- .../Components/Components.swift | 6 ------ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf05ce2..6ae806b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,12 +81,6 @@ jobs: echo "::error::The build has warnings." exit 1 fi - - name: UI tests - run: | - xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ - -configuration Debug -destination 'platform=macOS' \ - -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/UITests.xcresult" \ - test-without-building - name: Observe native package file panels env: TEST_RUNNER_IDT_PACKAGE_UI_TESTS: "1" @@ -95,6 +89,19 @@ jobs: -configuration Debug -destination 'platform=macOS' \ -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/PackageUITests.xcresult" \ -only-testing:iOSDeveloperToolkitUITests/PackageInspectionUITests test-without-building + - name: Retain native package panel observations + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: always() + with: + name: package-panel-observations + path: ${{ runner.temp }}/PackageUITests.xcresult + if-no-files-found: error + - name: UI tests + run: | + xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -configuration Debug -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/UITests.xcresult" \ + test-without-building - name: Render every page at the default and minimum window sizes run: | scripts/check-layout.sh \ diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index 63fd28f..0507d61 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -28,7 +28,8 @@ final class PackageInspectionUITests: XCTestCase { XCTAssertTrue(choose.waitForExistence(timeout: 10)) XCTAssertTrue(choose.isHittable) choose.click() - let panel = app.descendants(matching: .any)["file-open-panel"] + attach(app.debugDescription, name: "package-before-native-panel-lookup") + let panel = app.descendants(matching: .any)["open-panel"] XCTAssertTrue(panel.waitForExistence(timeout: 10)) attach(app.debugDescription, name: "package-native-open-panel") let fields = app.textFields.count diff --git a/App/iOSDeveloperToolkit/Components/Components.swift b/App/iOSDeveloperToolkit/Components/Components.swift index 450ae4d..acd2586 100644 --- a/App/iOSDeveloperToolkit/Components/Components.swift +++ b/App/iOSDeveloperToolkit/Components/Components.swift @@ -353,12 +353,6 @@ enum FilePanels { static func chooseFile(title: String, allowedExtensions: [String], directory: URL? = nil) -> URL? { let panel = NSOpenPanel() - panel.identifier = NSUserInterfaceItemIdentifier("file-open-panel") - panel.setAccessibilityIdentifier("file-open-panel") - if let confirmView = panel.defaultButtonCell?.controlView { - confirmView.identifier = NSUserInterfaceItemIdentifier("file-open-confirm") - confirmView.setAccessibilityIdentifier("file-open-confirm") - } panel.title = title panel.canChooseFiles = true panel.canChooseDirectories = allowedExtensions.contains("app") From 159b0740e0d614983602b301975eb6f895c9ffdf Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:39:48 -0800 Subject: [PATCH 06/20] hideouts/package-inspection: exercise recovery and normal Quit --- .github/workflows/ci.yml | 128 ++++++++++-- App/UITests/PackageInspectionUITests.swift | 227 +++++++++++++++++++-- 2 files changed, 326 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ae806b..795e527 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,54 @@ jobs: echo "::error::The build has warnings." exit 1 fi - - name: Observe native package file panels + - name: UI tests + run: | + xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ + -configuration Debug -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/UITests.xcresult" \ + test-without-building + - name: Build the package fixture host + run: | + xcrun swiftc -swift-version 6 -warnings-as-errors -parse-as-library \ + -I "$RUNNER_TEMP/dd/Build/Products/Debug" \ + "$RUNNER_TEMP/dd/Build/Products/Debug/ToolkitCore.o" \ + scripts/PackageInspectionFixtures.swift -o "$RUNNER_TEMP/package-fixtures" + - name: Prepare one owned package simulator and immutable package inputs + id: prepare_package + run: | + "$RUNNER_TEMP/package-fixtures" prepare "$RUNNER_TEMP/package-fixtures-data" + python3 - "$RUNNER_TEMP/package-fixtures-data/environment.env" <<'PY' + import os + import sys + import uuid + from pathlib import Path + + path = Path(sys.argv[1]) + if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size <= 8192: + raise SystemExit("The package fixture environment must be a bounded regular file.") + values = {} + for line in path.read_text().splitlines(): + key, separator, value = line.partition("=") + if not separator or not value or key in values or "\r" in value or "\x00" in value: + raise SystemExit("The package fixture environment has an invalid or duplicate entry.") + values[key] = value + expected_keys = {"IDT_PACKAGE_FIXTURE_DIRECTORY", "IDT_PACKAGE_FIXTURE_SIMULATOR", "IDT_PACKAGE_FIXTURE_OWNER", "IDT_PACKAGE_FIXTURE_VALID", "IDT_PACKAGE_FIXTURE_INVALID"} + if set(values) != expected_keys: + raise SystemExit("The package fixture environment has an unexpected schema.") + for key in ("IDT_PACKAGE_FIXTURE_SIMULATOR", "IDT_PACKAGE_FIXTURE_OWNER"): + if str(uuid.UUID(values[key])).upper() != values[key]: + raise SystemExit("The package fixture environment requires canonical UUIDs.") + directory = (Path(os.environ["RUNNER_TEMP"]) / "package-fixtures-data").resolve(strict=True) + expected_paths = {"IDT_PACKAGE_FIXTURE_DIRECTORY": directory, "IDT_PACKAGE_FIXTURE_VALID": directory / "ValidSimulator.app", "IDT_PACKAGE_FIXTURE_INVALID": directory / "Malformed.ipa"} + for key, expected in expected_paths.items(): + candidate = Path(values[key]) + if not candidate.is_absolute() or candidate.is_symlink() or candidate.resolve(strict=True) != expected: + raise SystemExit("The package fixture environment contains an unexpected input path.") + with open(os.environ["GITHUB_ENV"], "a") as output: + for key in sorted(values): + output.write("TEST_RUNNER_" + key + "=" + values[key] + "\n") + PY + - name: Hosted package inspection recovery and normal Quit acceptance env: TEST_RUNNER_IDT_PACKAGE_UI_TESTS: "1" run: | @@ -89,19 +136,72 @@ jobs: -configuration Debug -destination 'platform=macOS' \ -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/PackageUITests.xcresult" \ -only-testing:iOSDeveloperToolkitUITests/PackageInspectionUITests test-without-building - - name: Retain native package panel observations - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - if: always() - with: - name: package-panel-observations - path: ${{ runner.temp }}/PackageUITests.xcresult - if-no-files-found: error - - name: UI tests + xcrun xcresulttool get test-results summary --path "$RUNNER_TEMP/PackageUITests.xcresult" \ + > "$RUNNER_TEMP/PackageUITests-summary.json" + python3 - "$RUNNER_TEMP/PackageUITests-summary.json" <<'PY' + import json + import sys + from pathlib import Path + + summary = json.loads(Path(sys.argv[1]).read_text()) + expected = {"totalTestCount": 1, "passedTests": 1, "failedTests": 0, "skippedTests": 0} + if not isinstance(summary, dict) or any(type(summary.get(key)) is not int for key in expected): + raise SystemExit("Package acceptance did not produce valid execution counts.") + observed = {key: summary[key] for key in expected} + if observed != expected: + raise SystemExit(f"Package acceptance must execute and pass exactly one test: {observed}") + print(f"Native package acceptance executed: {observed}") + PY + - name: Independently verify package cleanup and original input integrity run: | - xcodebuild -project iOSDeveloperToolkit.xcodeproj -scheme iOSDeveloperToolkit \ - -configuration Debug -destination 'platform=macOS' \ - -derivedDataPath "$RUNNER_TEMP/dd" -resultBundlePath "$RUNNER_TEMP/UITests.xcresult" \ - test-without-building + xcrun xcresulttool export attachments --path "$RUNNER_TEMP/PackageUITests.xcresult" \ + --output-path "$RUNNER_TEMP/package-ui-attachments" + python3 - "$RUNNER_TEMP/package-ui-attachments" "$RUNNER_TEMP/PackageUITests-cleanup-receipt.json" <<'PY' + import json + import os + import re + import sys + from pathlib import Path + + directory = Path(sys.argv[1]).resolve(strict=True) + manifest_path = directory / "manifest.json" + if manifest_path.is_symlink() or not manifest_path.is_file() or not 0 < manifest_path.stat().st_size <= 16_777_216: + raise SystemExit("The exported native attachment manifest is missing or invalid.") + manifest = json.loads(manifest_path.read_text()) + if not isinstance(manifest, list) or not 0 < len(manifest) <= 64: + raise SystemExit("The exported native attachment manifest has an invalid test array.") + receipts = [] + for test in manifest: + if not isinstance(test, dict) or not isinstance(test.get("attachments"), list): + raise SystemExit("The exported native attachment manifest has an invalid test entry.") + if test.get("testIdentifier") != "PackageInspectionUITests/testPackageInspectionRecoveryAndNormalQuitCleanup()": + continue + for attachment in test["attachments"]: + if not isinstance(attachment, dict): + raise SystemExit("The exported native attachment manifest has an invalid attachment entry.") + name = attachment.get("suggestedHumanReadableName") + if isinstance(name, str) and name.startswith("package-cleanup-receipt"): + filename = attachment.get("exportedFileName") + if not isinstance(filename, str) or re.fullmatch(r"[A-Za-z0-9_.-]{1,255}", filename) is None or filename in (".", ".."): + raise SystemExit("The native cleanup receipt has an unsafe exported basename.") + path = directory / filename + if path.is_symlink() or not path.is_file() or path.resolve(strict=True).parent != directory or not 0 < path.stat().st_size <= 16384: + raise SystemExit("The native cleanup receipt must be a bounded exported regular file.") + receipts.append(path) + if len(receipts) != 1: + raise SystemExit("Native acceptance must retain exactly one actual package cleanup receipt.") + data = receipts[0].read_bytes() + receipt = json.loads(data) + if not isinstance(receipt, dict) or set(receipt) != {"preparedDirectories"}: + raise SystemExit("The actual native cleanup receipt has an invalid schema.") + with open(sys.argv[2], "xb") as output: + os.fchmod(output.fileno(), 0o600) + output.write(data) + PY + "$RUNNER_TEMP/package-fixtures" verify-cleanup "$RUNNER_TEMP/package-fixtures-data" "$RUNNER_TEMP/PackageUITests-cleanup-receipt.json" + - name: Verify cleanup of the owned package simulator + if: ${{ always() && (steps.prepare_package.outcome == 'success' || steps.prepare_package.outcome == 'failure' || steps.prepare_package.outcome == 'cancelled') }} + run: '"$RUNNER_TEMP/package-fixtures" cleanup "$RUNNER_TEMP/package-fixtures-data"' - name: Render every page at the default and minimum window sizes run: | scripts/check-layout.sh \ @@ -114,6 +214,8 @@ jobs: path: | ${{ runner.temp }}/UITests.xcresult ${{ runner.temp }}/PackageUITests.xcresult + ${{ runner.temp }}/PackageUITests-summary.json + ${{ runner.temp }}/PackageUITests-cleanup-receipt.json ${{ runner.temp }}/layout if-no-files-found: ignore diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index 0507d61..fb43a43 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -1,3 +1,5 @@ +import Darwin +import Foundation import XCTest /// Explicit hosted acceptance keeps native file panels and termination out of ordinary runs. @@ -5,6 +7,38 @@ import XCTest final class PackageInspectionUITests: XCTestCase { private var application: XCUIApplication? + struct Fixtures { + let simulator: String + let owner: String + let valid: String + let invalid: String + + static func load(environment: [String: String]) throws -> Fixtures { + let simulator = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_SIMULATOR"]) + let owner = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_OWNER"]) + let directory = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_DIRECTORY"]) + let valid = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_VALID"]) + let invalid = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_INVALID"]) + XCTAssertEqual(UUID(uuidString: simulator)?.uuidString, simulator) + XCTAssertEqual(UUID(uuidString: owner)?.uuidString, owner) + let root = URL(fileURLWithPath: directory, isDirectory: true).standardizedFileURL + XCTAssertTrue(directory.hasPrefix("/")) + XCTAssertTrue([directory, valid, invalid].allSatisfy { $0.utf8.count <= 4096 && !$0.contains("\0") && !$0.contains("\n") && !$0.contains("\r") }) + XCTAssertEqual(valid, root.appendingPathComponent("ValidSimulator.app").path) + XCTAssertEqual(invalid, root.appendingPathComponent("Malformed.ipa").path) + return Fixtures(simulator: simulator, owner: owner, valid: valid, invalid: invalid) + } + } + + struct Preparation { + let directory: String + let identifier: String + } + + struct CleanupReceipt: Encodable { + let preparedDirectories: [String] + } + override func setUp() async throws { continueAfterFailure = false try XCTSkipUnless(ProcessInfo.processInfo.environment["IDT_PACKAGE_UI_TESTS"] == "1", @@ -16,32 +50,193 @@ final class PackageInspectionUITests: XCTestCase { } func testPackageInspectionRecoveryAndNormalQuitCleanup() throws { + let fixtures = try Fixtures.load(environment: ProcessInfo.processInfo.environment) let app = XCUIApplication() application = app app.launchArguments = ["-ui-testing", "YES", "-demo-mode", "NO", "-workspace", "installApp", + "-package-acceptance-simulator", fixtures.simulator, + "-package-acceptance-owner", fixtures.owner, "-ApplePersistenceIgnoreState", "YES"] app.launchEnvironment["IDT_RESTORE_HELPERS"] = "" app.launch() app.activate() XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) - let choose = app.buttons["choose-package"] - XCTAssertTrue(choose.waitForExistence(timeout: 10)) - XCTAssertTrue(choose.isHittable) + XCTAssertFalse(element("demo-banner").exists) + wait(element("connection-summary"), predicate: "exists == true AND NOT (label CONTAINS 'Simulators: Not checked yet')", timeout: 75) + XCTAssertNotNil(element("connection-summary").label.range(of: "Simulators: [1-9][0-9]* devices", options: .regularExpression)) + let picker = element("target-picker") + wait(picker, predicate: "exists == true AND hittable == true", timeout: 10) + picker.click() + let simulator = element("target-simulator-\(fixtures.simulator)") + wait(simulator, predicate: "exists == true AND hittable == true", timeout: 10) + simulator.click() + wait(simulator, predicate: "exists == false", timeout: 10) + XCTAssertFalse(element("install-package").exists) + + try choosePackage(fixtures.valid) + let first = try requirePreparation(original: fixtures.valid) + XCTAssertEqual(try value("package-selection"), "ValidSimulator.app") + XCTAssertFalse(element("package-inspection-failed").exists) + attachState("package-first-valid") + + // This is a genuine malformed archive. No error or preparation result is injected. + try choosePackage(fixtures.invalid) + wait(element("error-message"), predicate: "exists == true", timeout: 120) + XCTAssertEqual(try value("error-message"), "The file is not a valid ZIP-based package.") + attachState("package-malformed-error") + let dismiss = element("error-dismiss") + wait(dismiss, predicate: "exists == true AND hittable == true", timeout: 10) + dismiss.click() + wait(element("error-message"), predicate: "exists == false", timeout: 10) + wait(element("package-inspection-failed"), predicate: "exists == true", timeout: 10) + wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) + XCTAssertEqual(try value("package-selection"), "Malformed.ipa") + XCTAssertEqual(try value("package-prepared-id"), "none") + XCTAssertEqual(try value("package-prepared-directory"), "none") + XCTAssertEqual(try value("package-original-url"), "none") + wait(element("install-package"), predicate: "exists == true AND enabled == false", timeout: 10) + try requireAbsent(first.directory) + XCTAssertFalse(element("running-operations").exists) + attachState("package-malformed-denied") + + try choosePackage(fixtures.valid) + let recovered = try requirePreparation(original: fixtures.valid) + XCTAssertNotEqual(recovered.directory, first.directory) + XCTAssertNotEqual(recovered.identifier, first.identifier) + XCTAssertFalse(element("package-inspection-failed").exists) + let install = element("install-package") + reveal(install) + install.click() + wait(element("confirmation-detail"), predicate: "exists == true", timeout: 10) + XCTAssertEqual(try value("confirmation-title"), "Install Package Fixture?") + XCTAssertEqual(try value("confirmation-detail"), "ValidSimulator.app will be installed on IDT Package \(fixtures.owner). iOS still checks the signature, provisioning, and entitlements.") + XCTAssertEqual(app.sheets.count, 1) + wait(element("confirm-button"), predicate: "exists == true AND enabled == false", timeout: 10) + XCTAssertTrue(element("confirmation-field").exists) + attachState("package-confirmation") + let cancel = element("confirmation-cancel") + wait(cancel, predicate: "exists == true AND hittable == true", timeout: 10) + cancel.click() + wait(element("confirm-button"), predicate: "exists == false", timeout: 10) + XCTAssertEqual(app.sheets.count, 0) + XCTAssertEqual(try value("package-prepared-directory"), recovered.directory) + XCTAssertEqual(try value("package-prepared-id"), recovered.identifier) + XCTAssertTrue(install.isEnabled) + XCTAssertFalse(element("running-operations").exists) + try requirePrivateDirectory(recovered.directory) + attachState("package-cancel-preserved") + + // Normal Quit exercises AppDelegate's termination reply and production cleanup. + // terminate() in tearDown is failure cleanup and cannot establish this assertion. + app.typeKey("q", modifierFlags: [.command]) + XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "Normal Command-Q must exit after removing its idle prepared package") + try requireAbsent(first.directory) + try requireAbsent(recovered.directory) + let data = try JSONEncoder().encode(CleanupReceipt(preparedDirectories: [first.directory, recovered.directory])) + attach(String(decoding: data, as: UTF8.self), name: "package-cleanup-receipt") + } + + /// Uses the identifiers recorded from the real AppKit panel on the hosted Mac. + private func choosePackage(_ path: String) throws { + let choose = element("choose-package") + reveal(choose) choose.click() - attach(app.debugDescription, name: "package-before-native-panel-lookup") - let panel = app.descendants(matching: .any)["open-panel"] - XCTAssertTrue(panel.waitForExistence(timeout: 10)) - attach(app.debugDescription, name: "package-native-open-panel") - let fields = app.textFields.count + let panel = element("open-panel") + wait(panel, predicate: "exists == true", timeout: 10) panel.typeKey("g", modifierFlags: [.command, .shift]) - let fieldAppeared = app.textFields.element(boundBy: fields).waitForExistence(timeout: 5) - attach(app.debugDescription, name: "package-native-go-to-panel") - XCTAssertTrue(fieldAppeared) - app.typeKey(.escape, modifierFlags: []) - app.typeKey(.escape, modifierFlags: []) - XCTAssertTrue(panel.waitForNonExistence(timeout: 5)) - app.typeKey("q", modifierFlags: [.command]) - XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "Normal Command-Q must exit the test app") + let goTo = panel.descendants(matching: .any)["GoToWindow"] + wait(goTo, predicate: "exists == true", timeout: 5) + let field = goTo.textFields["PathTextField"] + wait(field, predicate: "exists == true AND hittable == true", timeout: 5) + field.click() + field.typeKey("a", modifierFlags: [.command]) + field.typeText(path) + XCTAssertEqual(try XCTUnwrap(field.value as? String), path) + field.typeKey(.return, modifierFlags: []) + wait(goTo, predicate: "exists == false", timeout: 5) + let open = panel.buttons["OKButton"] + wait(open, predicate: "exists == true AND enabled == true AND hittable == true", timeout: 10) + attach(application!.debugDescription, name: "package-native-selected-input") + open.click() + wait(panel, predicate: "exists == false", timeout: 10) + } + + private func element(_ identifier: String) -> XCUIElement { + application!.descendants(matching: .any)[identifier] + } + + private func value(_ identifier: String) throws -> String { + try XCTUnwrap(element(identifier).value as? String, "The identified native control must expose a String value: \(identifier)") + } + + private func wait(_ control: XCUIElement, predicate: String, timeout: TimeInterval) { + let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(format: predicate), object: control) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: timeout), .completed, + "The native control did not satisfy \(predicate): \(control)") + } + + private func requirePreparation(original: String) throws -> Preparation { + wait(element("package-prepared-id"), predicate: "exists == true AND value != 'none'", timeout: 120) + wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) + wait(element("install-package"), predicate: "exists == true AND enabled == true", timeout: 10) + XCTAssertFalse(element("package-inspection-running").exists) + XCTAssertFalse(element("error-message").exists) + XCTAssertEqual(try value("package-original-url"), original) + let directory = try value("package-prepared-directory") + let identifier = try value("package-prepared-id") + XCTAssertEqual(UUID(uuidString: identifier)?.uuidString, identifier) + try requirePrivateDirectory(directory) + let copy = URL(fileURLWithPath: directory).appendingPathComponent("ValidSimulator.app") + var metadata = stat() + XCTAssertEqual(lstat(copy.path, &metadata), 0, "The actual private package copy must exist while the app is alive") + XCTAssertEqual(metadata.st_mode & S_IFMT, S_IFDIR) + XCTAssertNotEqual(copy.path, original) + return Preparation(directory: directory, identifier: identifier) + } + + private func requirePrivateDirectory(_ path: String) throws { + let directory = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + XCTAssertTrue(path.hasPrefix("/")) + XCTAssertTrue(directory.lastPathComponent.hasPrefix("idt-prepared-app.")) + var metadata = stat() + XCTAssertEqual(lstat(directory.path, &metadata), 0, "The recorded private preparation must actually exist before Quit") + XCTAssertEqual(metadata.st_mode & S_IFMT, S_IFDIR) + XCTAssertEqual(metadata.st_uid, getuid()) + XCTAssertEqual(metadata.st_mode & 0o777, 0o700) + } + + private func requireAbsent(_ path: String) throws { + var metadata = stat() + let result = lstat(path, &metadata) + let code = errno + XCTAssertEqual(result, -1, "The exact recorded preparation must have been removed") + XCTAssertEqual(code, ENOENT, "Absence must be verified without masking an access failure") + } + + private func reveal(_ control: XCUIElement) { + let app = application! + let pages = app.scrollViews.containing(.button, identifier: "choose-package") + XCTAssertEqual(pages.count, 1, "Package controls must belong to one identified page scroll view") + let page = pages.element + for _ in 0..<8 { + if control.isHittable { break } + let viewport = page.frame + let frame = control.frame + XCTAssertGreaterThan(viewport.height, 0) + XCTAssertGreaterThan(frame.height, 0) + let deltaY = frame.maxY > viewport.maxY ? -viewport.height / 2 : viewport.height / 2 + page.scroll(byDeltaX: 0, deltaY: deltaY) + } + wait(control, predicate: "exists == true AND hittable == true", timeout: 5) + } + + private func attachState(_ name: String) { + let app = application! + attach(app.debugDescription, name: name) + let screenshot = XCTAttachment(screenshot: app.windows["main-AppWindow-1"].screenshot()) + screenshot.name = name + "-window" + screenshot.lifetime = .keepAlways + add(screenshot) } private func attach(_ text: String, name: String) { From c711e009077e7f534e16b5ce5fb9592e197aa8cb Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:23:20 -0800 Subject: [PATCH 07/20] hideouts/package-inspection: observe owned simulator readiness --- App/UITests/PackageInspectionUITests.swift | 29 +++++++++++++++++-- App/iOSDeveloperToolkit/Views/AppsViews.swift | 5 ++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index fb43a43..e342939 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -62,8 +62,10 @@ final class PackageInspectionUITests: XCTestCase { app.activate() XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) XCTAssertFalse(element("demo-banner").exists) - wait(element("connection-summary"), predicate: "exists == true AND NOT (label CONTAINS 'Simulators: Not checked yet')", timeout: 75) - XCTAssertNotNil(element("connection-summary").label.range(of: "Simulators: [1-9][0-9]* devices", options: .regularExpression)) + let readiness = element("package-target-readiness") + attachReadiness("package-discovery-before-wait") + wait(readiness, predicate: "exists == true AND value == 'ready'", timeout: 75) + attachReadiness("package-owned-target-ready") let picker = element("target-picker") wait(picker, predicate: "exists == true AND hittable == true", timeout: 10) picker.click() @@ -171,10 +173,31 @@ final class PackageInspectionUITests: XCTestCase { private func wait(_ control: XCUIElement, predicate: String, timeout: TimeInterval) { let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(format: predicate), object: control) - XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: timeout), .completed, + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + if result != .completed, let application, application.state != .notRunning { + attachReadiness("package-wait-readiness") + attach(application.debugDescription, name: "package-wait-native-hierarchy") + } + XCTAssertEqual(result, .completed, "The native control did not satisfy \(predicate): \(control)") } + private func attachReadiness(_ name: String) { + let summary = element("connection-summary") + let label = summary.exists ? String(summary.label.prefix(4096)) : "" + let summaryValue = diagnosticValue("connection-summary") + let readiness = diagnosticValue("package-target-readiness") + let status = diagnosticValue("package-discovery-status") + attach("label: \(label)\nvalue: \(summaryValue)\nowned-target: \(readiness)\nsource-status: \(status)", name: name) + } + + private func diagnosticValue(_ identifier: String) -> String { + let control = element(identifier) + guard control.exists else { return "" } + guard let value = control.value as? String else { return "" } + return String(value.prefix(4096)) + } + private func requirePreparation(original: String) throws -> Preparation { wait(element("package-prepared-id"), predicate: "exists == true AND value != 'none'", timeout: 120) wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index 4a55af0..837bb5b 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -121,6 +121,11 @@ struct InstallAppView: View { #if DEBUG if model.packageAcceptanceTarget != nil { VStack(alignment: .leading, spacing: 2) { + let targets = model.simulatorDevices + Text(model.snapshot.simulators.isAvailable && targets.count == 1 && targets.first?.simulatorState == .booted ? "ready" : "waiting") + .accessibilityIdentifier("package-target-readiness") + Text(model.snapshot.simulators.summary) + .accessibilityIdentifier("package-discovery-status") Text(install.preparedPackage?.directory.path ?? "none") .accessibilityIdentifier("package-prepared-directory") Text(install.preparedPackage?.id.uuidString ?? "none") From 8f1a7c2abad83b3c1c17732279e2472521db5358 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:59:21 -0800 Subject: [PATCH 08/20] hideouts/package-inspection: preserve targets during cancelled refreshes --- App/UITests/PackageInspectionUITests.swift | 3 ++- Sources/DeviceKit/Discovery/DeviceDiscovery.swift | 8 ++++++-- Tests/ToolkitCoreTests/CommandRunnerTests.swift | 8 ++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index e342939..8cad5dd 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -155,9 +155,10 @@ final class PackageInspectionUITests: XCTestCase { field.typeText(path) XCTAssertEqual(try XCTUnwrap(field.value as? String), path) field.typeKey(.return, modifierFlags: []) - wait(goTo, predicate: "exists == false", timeout: 5) + wait(application!.textFields["PathTextField"], predicate: "exists == false", timeout: 5) let open = panel.buttons["OKButton"] wait(open, predicate: "exists == true AND enabled == true AND hittable == true", timeout: 10) + XCTAssertEqual(try value("package-target-readiness"), "ready", "Choosing a local package must preserve the owned simulator target") attach(application!.debugDescription, name: "package-native-selected-input") open.click() wait(panel, predicate: "exists == false", timeout: 10) diff --git a/Sources/DeviceKit/Discovery/DeviceDiscovery.swift b/Sources/DeviceKit/Discovery/DeviceDiscovery.swift index afed333..175731a 100644 --- a/Sources/DeviceKit/Discovery/DeviceDiscovery.swift +++ b/Sources/DeviceKit/Discovery/DeviceDiscovery.swift @@ -406,12 +406,16 @@ public actor DeviceDiscovery { } } + /// A cancelled refresh cannot replace the last completed simulator snapshot. private func refreshSimulators() async { - guard let client = configuration.simulators else { return } + guard !Task.isCancelled, let client = configuration.simulators else { return } do { - simulatorRecords = try await client.list() + let records = try await client.list() + guard !Task.isCancelled else { return } + simulatorRecords = records snapshot.simulators = .available(count: simulatorRecords.filter(\.isAvailable).count) } catch { + guard !Task.isCancelled else { return } simulatorRecords = [] let reason = (error as? ToolkitError).map { $0.kind == .toolMissing ? "Xcode is not installed, so simulators are unavailable." : $0.message } ?? error.localizedDescription snapshot.simulators = .unavailable(reason: reason) diff --git a/Tests/ToolkitCoreTests/CommandRunnerTests.swift b/Tests/ToolkitCoreTests/CommandRunnerTests.swift index 8f18776..76578c3 100644 --- a/Tests/ToolkitCoreTests/CommandRunnerTests.swift +++ b/Tests/ToolkitCoreTests/CommandRunnerTests.swift @@ -101,8 +101,9 @@ struct CommandRunnerTests { defer { close(descriptor) } // The child acknowledges SIGTERM but waits for a test-controlled release before exit. // A FIFO makes each assertion depend on a real process state rather than a delay. + // After release, force-stop the owned sleep: its SIGTERM policy is not the parent's contract. let script = """ - trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; kill "$child" 2>/dev/null; wait "$child" 2>/dev/null; printf "after release\\n"; exit 0' TERM + trap 'printf "cancel received\\n"; IFS= read -r release < "$1"; printf "phase: fifo-read=%s\\n" "$?" >&2; kill -KILL "$child" 2>/dev/null; printf "phase: child-kill=%s\\n" "$?" >&2; wait "$child" 2>/dev/null; printf "phase: child-wait=%s\\n" "$?" >&2; printf "after release\\n"; exit 0' TERM sleep 30 & child=$! printf 'ready:%s\\n' "$$" wait "$child" @@ -113,6 +114,7 @@ struct CommandRunnerTests { let cancellation = CommandCancellation() let (lines, delivered) = AsyncStream.makeStream() let ended = LockedValue(false) + let diagnostics = LockedValue(Data()) let task = Task { defer { ended.withLock { $0 = true }; delivered.finish() } var buffered = "" @@ -123,6 +125,8 @@ struct CommandRunnerTests { delivered.yield(String(buffered[.. Date: Fri, 6 Nov 2026 10:08:39 -0800 Subject: [PATCH 09/20] hideouts/package-inspection: verify late completed package disposal Extend hosted native acceptance with genuine A/B preparations and controlled late A delivery through the real chooser. Retain exact private-copy cleanup, source hashes and simulator inventory checks. Local strict parallel tests, headless App/UI build, signature verification, helper compilation and independent review pass. Hosted acceptance and candidate Swift CodeQL remain pending; active-reader Quit and physical installation are separate gates. --- .github/workflows/ci.yml | 6 +- App/UITests/PackageInspectionUITests.swift | 106 ++++++++++++++++-- .../Model/FeatureModels.swift | 80 ++++++++++++- App/iOSDeveloperToolkit/Views/AppsViews.swift | 31 ++++- scripts/PackageInspectionFixtures.swift | 36 +++--- 5 files changed, 229 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 795e527..2815d16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,14 +112,14 @@ jobs: if not separator or not value or key in values or "\r" in value or "\x00" in value: raise SystemExit("The package fixture environment has an invalid or duplicate entry.") values[key] = value - expected_keys = {"IDT_PACKAGE_FIXTURE_DIRECTORY", "IDT_PACKAGE_FIXTURE_SIMULATOR", "IDT_PACKAGE_FIXTURE_OWNER", "IDT_PACKAGE_FIXTURE_VALID", "IDT_PACKAGE_FIXTURE_INVALID"} + expected_keys = {"IDT_PACKAGE_FIXTURE_DIRECTORY", "IDT_PACKAGE_FIXTURE_SIMULATOR", "IDT_PACKAGE_FIXTURE_OWNER", "IDT_PACKAGE_FIXTURE_VALID", "IDT_PACKAGE_FIXTURE_VALID_B", "IDT_PACKAGE_FIXTURE_INVALID"} if set(values) != expected_keys: raise SystemExit("The package fixture environment has an unexpected schema.") for key in ("IDT_PACKAGE_FIXTURE_SIMULATOR", "IDT_PACKAGE_FIXTURE_OWNER"): if str(uuid.UUID(values[key])).upper() != values[key]: raise SystemExit("The package fixture environment requires canonical UUIDs.") directory = (Path(os.environ["RUNNER_TEMP"]) / "package-fixtures-data").resolve(strict=True) - expected_paths = {"IDT_PACKAGE_FIXTURE_DIRECTORY": directory, "IDT_PACKAGE_FIXTURE_VALID": directory / "ValidSimulator.app", "IDT_PACKAGE_FIXTURE_INVALID": directory / "Malformed.ipa"} + expected_paths = {"IDT_PACKAGE_FIXTURE_DIRECTORY": directory, "IDT_PACKAGE_FIXTURE_VALID": directory / "ValidSimulator.app", "IDT_PACKAGE_FIXTURE_VALID_B": directory / "ValidSimulatorB.app", "IDT_PACKAGE_FIXTURE_INVALID": directory / "Malformed.ipa"} for key, expected in expected_paths.items(): candidate = Path(values[key]) if not candidate.is_absolute() or candidate.is_symlink() or candidate.resolve(strict=True) != expected: @@ -128,7 +128,7 @@ jobs: for key in sorted(values): output.write("TEST_RUNNER_" + key + "=" + values[key] + "\n") PY - - name: Hosted package inspection recovery and normal Quit acceptance + - name: Hosted package recovery, late completion and normal Quit acceptance env: TEST_RUNNER_IDT_PACKAGE_UI_TESTS: "1" run: | diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index 8cad5dd..8ec7cf9 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -11,6 +11,7 @@ final class PackageInspectionUITests: XCTestCase { let simulator: String let owner: String let valid: String + let validB: String let invalid: String static func load(environment: [String: String]) throws -> Fixtures { @@ -18,15 +19,17 @@ final class PackageInspectionUITests: XCTestCase { let owner = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_OWNER"]) let directory = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_DIRECTORY"]) let valid = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_VALID"]) + let validB = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_VALID_B"]) let invalid = try XCTUnwrap(environment["IDT_PACKAGE_FIXTURE_INVALID"]) XCTAssertEqual(UUID(uuidString: simulator)?.uuidString, simulator) XCTAssertEqual(UUID(uuidString: owner)?.uuidString, owner) let root = URL(fileURLWithPath: directory, isDirectory: true).standardizedFileURL XCTAssertTrue(directory.hasPrefix("/")) - XCTAssertTrue([directory, valid, invalid].allSatisfy { $0.utf8.count <= 4096 && !$0.contains("\0") && !$0.contains("\n") && !$0.contains("\r") }) + XCTAssertTrue([directory, valid, validB, invalid].allSatisfy { $0.utf8.count <= 4096 && !$0.contains("\0") && !$0.contains("\n") && !$0.contains("\r") }) XCTAssertEqual(valid, root.appendingPathComponent("ValidSimulator.app").path) + XCTAssertEqual(validB, root.appendingPathComponent("ValidSimulatorB.app").path) XCTAssertEqual(invalid, root.appendingPathComponent("Malformed.ipa").path) - return Fixtures(simulator: simulator, owner: owner, valid: valid, invalid: invalid) + return Fixtures(simulator: simulator, owner: owner, valid: valid, validB: validB, invalid: invalid) } } @@ -76,7 +79,7 @@ final class PackageInspectionUITests: XCTestCase { XCTAssertFalse(element("install-package").exists) try choosePackage(fixtures.valid) - let first = try requirePreparation(original: fixtures.valid) + let first = try requirePreparation(original: fixtures.valid, activeOperations: 0) XCTAssertEqual(try value("package-selection"), "ValidSimulator.app") XCTAssertFalse(element("package-inspection-failed").exists) attachState("package-first-valid") @@ -102,7 +105,7 @@ final class PackageInspectionUITests: XCTestCase { attachState("package-malformed-denied") try choosePackage(fixtures.valid) - let recovered = try requirePreparation(original: fixtures.valid) + let recovered = try requirePreparation(original: fixtures.valid, activeOperations: 0) XCTAssertNotEqual(recovered.directory, first.directory) XCTAssertNotEqual(recovered.identifier, first.identifier) XCTAssertFalse(element("package-inspection-failed").exists) @@ -128,13 +131,84 @@ final class PackageInspectionUITests: XCTestCase { try requirePrivateDirectory(recovered.directory) attachState("package-cancel-preserved") + // Only completion order is controlled. A and B both perform genuine native inspection. + let arm = element("package-completion-arm") + reveal(arm) + XCTAssertTrue(arm.isEnabled) + arm.click() + wait(element("package-completion-state"), predicate: "value == 'armed'", timeout: 5) + try choosePackage(fixtures.valid) + wait(element("package-completion-state"), predicate: "value == 'held'", timeout: 120) + let late = try heldPreparation(original: fixtures.valid) + XCTAssertNotEqual(late.directory, recovered.directory) + XCTAssertNotEqual(late.identifier, recovered.identifier) + try requireAbsent(recovered.directory) + XCTAssertEqual(try value("package-prepared-id"), "none") + XCTAssertEqual(try value("package-selection"), "ValidSimulator.app") + wait(install, predicate: "exists == true AND enabled == false", timeout: 10) + wait(element("package-active-operations"), predicate: "value == '1'", timeout: 10) + XCTAssertTrue(element("package-inspection-running").exists) + attachState("package-completed-a-held") + + try choosePackage(fixtures.validB) + let current = try requirePreparation(original: fixtures.validB, activeOperations: 1) + XCTAssertEqual(try value("package-selection"), "ValidSimulatorB.app") + XCTAssertNotEqual(current.directory, late.directory) + XCTAssertNotEqual(current.identifier, late.identifier) + XCTAssertEqual(try value("package-completion-state"), "held") + XCTAssertEqual(try value("package-held-cancelled"), "pending") + try requirePrivateDirectory(late.directory) + attachState("package-b-ready-a-held") + + let release = element("package-completion-release") + reveal(release) + XCTAssertTrue(release.isEnabled) + release.click() + wait(element("package-completion-state"), predicate: "value == 'delivered'", timeout: 10) + XCTAssertEqual(try value("package-held-cancelled"), "true", "A's actual inspection work task must observe the cancellation caused by selecting B") + wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) + try requireAbsent(late.directory) + XCTAssertEqual(try value("package-prepared-directory"), current.directory) + XCTAssertEqual(try value("package-prepared-id"), current.identifier) + XCTAssertEqual(try value("package-original-url"), fixtures.validB) + XCTAssertEqual(try value("package-selection"), "ValidSimulatorB.app") + XCTAssertFalse(element("package-inspection-running").exists) + XCTAssertFalse(element("package-inspection-failed").exists) + XCTAssertFalse(element("error-message").exists) + XCTAssertTrue(install.isEnabled) + XCTAssertFalse(element("running-operations").exists) + try requirePrivateDirectory(current.directory) + attachState("package-late-a-disposed-b-preserved") + + reveal(install) + install.click() + wait(element("confirmation-detail"), predicate: "exists == true", timeout: 10) + XCTAssertEqual(try value("confirmation-title"), "Install Package Fixture B?") + XCTAssertEqual(try value("confirmation-detail"), "ValidSimulatorB.app will be installed on IDT Package \(fixtures.owner). iOS still checks the signature, provisioning, and entitlements.") + XCTAssertEqual(app.sheets.count, 1) + wait(element("confirm-button"), predicate: "exists == true AND enabled == false", timeout: 10) + XCTAssertTrue(element("confirmation-field").exists) + attachState("package-b-confirmation-after-late-a") + wait(cancel, predicate: "exists == true AND hittable == true", timeout: 10) + cancel.click() + wait(element("confirm-button"), predicate: "exists == false", timeout: 10) + XCTAssertEqual(app.sheets.count, 0) + XCTAssertEqual(try value("package-prepared-directory"), current.directory) + XCTAssertEqual(try value("package-prepared-id"), current.identifier) + XCTAssertTrue(install.isEnabled) + XCTAssertFalse(element("running-operations").exists) + try requirePrivateDirectory(current.directory) + attachState("package-b-cancel-preserved") + // Normal Quit exercises AppDelegate's termination reply and production cleanup. // terminate() in tearDown is failure cleanup and cannot establish this assertion. app.typeKey("q", modifierFlags: [.command]) XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "Normal Command-Q must exit after removing its idle prepared package") try requireAbsent(first.directory) try requireAbsent(recovered.directory) - let data = try JSONEncoder().encode(CleanupReceipt(preparedDirectories: [first.directory, recovered.directory])) + try requireAbsent(late.directory) + try requireAbsent(current.directory) + let data = try JSONEncoder().encode(CleanupReceipt(preparedDirectories: [first.directory, recovered.directory, late.directory, current.directory])) attach(String(decoding: data, as: UTF8.self), name: "package-cleanup-receipt") } @@ -199,9 +273,9 @@ final class PackageInspectionUITests: XCTestCase { return String(value.prefix(4096)) } - private func requirePreparation(original: String) throws -> Preparation { + private func requirePreparation(original: String, activeOperations: Int) throws -> Preparation { wait(element("package-prepared-id"), predicate: "exists == true AND value != 'none'", timeout: 120) - wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) + wait(element("package-active-operations"), predicate: "value == '\(activeOperations)'", timeout: 10) wait(element("install-package"), predicate: "exists == true AND enabled == true", timeout: 10) XCTAssertFalse(element("package-inspection-running").exists) XCTAssertFalse(element("error-message").exists) @@ -210,12 +284,26 @@ final class PackageInspectionUITests: XCTestCase { let identifier = try value("package-prepared-id") XCTAssertEqual(UUID(uuidString: identifier)?.uuidString, identifier) try requirePrivateDirectory(directory) - let copy = URL(fileURLWithPath: directory).appendingPathComponent("ValidSimulator.app") + try requirePackageCopy(directory: directory, original: original) + return Preparation(directory: directory, identifier: identifier) + } + + private func heldPreparation(original: String) throws -> Preparation { + XCTAssertEqual(try value("package-held-original-url"), original) + let directory = try value("package-held-directory") + let identifier = try value("package-held-id") + XCTAssertEqual(UUID(uuidString: identifier)?.uuidString, identifier) + try requirePrivateDirectory(directory) + try requirePackageCopy(directory: directory, original: original) + return Preparation(directory: directory, identifier: identifier) + } + + private func requirePackageCopy(directory: String, original: String) throws { + let copy = URL(fileURLWithPath: directory).appendingPathComponent(URL(fileURLWithPath: original).lastPathComponent) var metadata = stat() XCTAssertEqual(lstat(copy.path, &metadata), 0, "The actual private package copy must exist while the app is alive") XCTAssertEqual(metadata.st_mode & S_IFMT, S_IFDIR) XCTAssertNotEqual(copy.path, original) - return Preparation(directory: directory, identifier: identifier) } private func requirePrivateDirectory(_ path: String) throws { diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift index db3e67c..3bc1b28 100644 --- a/App/iOSDeveloperToolkit/Model/FeatureModels.swift +++ b/App/iOSDeveloperToolkit/Model/FeatureModels.swift @@ -132,6 +132,80 @@ final class InstallModel { private var activePreparations = 0 private var isShuttingDown = false private var cleanupWaiters: [CheckedContinuation] = [] +#if DEBUG + enum CompletionAcceptanceState: String { + case idle, armed, held, released, delivered, expired + } + + private(set) var completionAcceptanceState: CompletionAcceptanceState = .idle + private(set) var heldPreparationDirectory: String? + private(set) var heldPreparationID: UUID? + private(set) var heldPreparationOriginalURL: URL? + private(set) var heldPreparationWasCancelled: Bool? + private var completionContinuation: CheckedContinuation? + private var completionDeadline: Task? + + func armCompletionAcceptance(app: AppModel) throws(ToolkitError) { + guard app.packageAcceptanceTarget != nil, completionAcceptanceState == .idle else { + throw ToolkitError(.invalidInput, message: "The package completion acceptance gate cannot be armed.", recovery: "Launch a fresh owned-simulator package acceptance session before arming its one-shot gate.", technicalDetail: nil) + } + completionAcceptanceState = .armed + } + + func releaseCompletionAcceptance(app: AppModel) throws(ToolkitError) { + guard app.packageAcceptanceTarget != nil, completionAcceptanceState == .held, + let continuation = completionContinuation else { + throw ToolkitError(.invalidInput, message: "There is no completed package preparation to release.", recovery: "Wait for the owned-simulator package acceptance gate to report held.", technicalDetail: nil) + } + completionContinuation = nil + completionDeadline?.cancel() + completionDeadline = nil + completionAcceptanceState = .released + continuation.resume() + } + + /// Acknowledges the real chooser caller after its production selection handling returned. + func packageChoiceReturned(_ url: URL) { + if completionAcceptanceState == .released, url == heldPreparationOriginalURL { + completionAcceptanceState = .delivered + } + } + + /// Holds a genuine completed preparation outside the serial Security.framework executor. + /// Its async frame owns the package until delivery, including cancellation and expiry, so + /// the production selection-generation check must dispose of an actually delivered stale A. + private func holdCompletedPreparationIfArmed(_ package: PreparedAppPackage, app: AppModel) async { + guard app.packageAcceptanceTarget != nil, completionAcceptanceState == .armed else { return } + heldPreparationDirectory = package.directory.path + heldPreparationID = package.id + heldPreparationOriginalURL = package.originalURL + await withCheckedContinuation { continuation in + completionContinuation = continuation + completionAcceptanceState = .held + completionDeadline = Task { @MainActor [weak self, weak app] in + do { + try await Task.sleep(for: .seconds(180)) + } catch is CancellationError { + return + } catch { + self?.expireCompletionAcceptance(app: app, cause: error.localizedDescription) + return + } + self?.expireCompletionAcceptance(app: app, cause: nil) + } + } + heldPreparationWasCancelled = Task.isCancelled + } + + private func expireCompletionAcceptance(app: AppModel?, cause: String?) { + guard completionAcceptanceState == .held, let continuation = completionContinuation else { return } + completionContinuation = nil + completionDeadline = nil + completionAcceptanceState = .expired + app?.present(ToolkitError(.timedOut, message: "The completed package acceptance result was not released within 180 seconds.", recovery: "Inspect the native acceptance failure and rerun the test; the actual package result will finish through normal ownership cleanup.", technicalDetail: cause)) + continuation.resume() + } +#endif var hasPreparedResources: Bool { preparedPackage != nil || !stalePackages.isEmpty || activePreparations > 0 || isInstalling } @@ -176,7 +250,11 @@ final class InstallModel { resumeCleanupIfIdle() } let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path], onStart: { self.inspectionOperation = $0 }) { _ in - try await PreparedAppPackage.prepareAsync(url) + let package = try await PreparedAppPackage.prepareAsync(url) +#if DEBUG + await self.holdCompletedPreparationIfArmed(package, app: app) +#endif + return package } guard selectionID == generation else { if let result { diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index 837bb5b..d2a60d0 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -107,7 +107,12 @@ struct InstallAppView: View { HStack { Button("Choose .ipa or .app…") { if let url = FilePanels.chooseFile(title: "Choose an app package", allowedExtensions: ["ipa", "app"]) { - Task { await install.choose(url, app: model) } + Task { + await install.choose(url, app: model) +#if DEBUG + if model.packageAcceptanceTarget != nil { install.packageChoiceReturned(url) } +#endif + } } } .disabled(install.isInstalling) @@ -134,6 +139,30 @@ struct InstallAppView: View { .accessibilityIdentifier("package-original-url") Text(String(model.operations.count)) .accessibilityIdentifier("package-active-operations") + Text(install.completionAcceptanceState.rawValue) + .accessibilityIdentifier("package-completion-state") + Text(install.heldPreparationDirectory ?? "none") + .accessibilityIdentifier("package-held-directory") + Text(install.heldPreparationID?.uuidString ?? "none") + .accessibilityIdentifier("package-held-id") + Text(install.heldPreparationOriginalURL?.path ?? "none") + .accessibilityIdentifier("package-held-original-url") + Text(install.heldPreparationWasCancelled.map(String.init) ?? "pending") + .accessibilityIdentifier("package-held-cancelled") + HStack { + Button("Arm completed package acceptance") { + do { try install.armCompletionAcceptance(app: model) } + catch { model.present(error) } + } + .disabled(install.completionAcceptanceState != .idle) + .accessibilityIdentifier("package-completion-arm") + Button("Release completed package") { + do { try install.releaseCompletionAcceptance(app: model) } + catch { model.present(error) } + } + .disabled(install.completionAcceptanceState != .held) + .accessibilityIdentifier("package-completion-release") + } } .font(.caption.monospaced()) .lineLimit(1) diff --git a/scripts/PackageInspectionFixtures.swift b/scripts/PackageInspectionFixtures.swift index f363645..301fb34 100644 --- a/scripts/PackageInspectionFixtures.swift +++ b/scripts/PackageInspectionFixtures.swift @@ -43,8 +43,10 @@ struct PackageInspectionFixtures { } static let validName = "ValidSimulator.app" + static let validBName = "ValidSimulatorB.app" static let invalidName = "Malformed.ipa" static let fixtureBundle = "io.hideouts.idt.package.fixture" + static let fixtureBBundle = "io.hideouts.idt.package.fixture.b" static func main() async throws { let arguments = CommandLine.arguments @@ -85,15 +87,16 @@ struct PackageInspectionFixtures { try await register(manifest, directory: directory, runner: runner) _ = try await command(["simctl", "bootstatus", identifier, "-b"], timeout: 600, runner: runner) try await requireOwnedSimulator(manifest, runner: runner) - let app = try await fixture(directory: directory, runner: runner) + let app = try await fixture(directory: directory, name: validName, bundle: fixtureBundle, displayName: "Package Fixture", runner: runner) + let appB = try await fixture(directory: directory, name: validBName, bundle: fixtureBBundle, displayName: "Package Fixture B", runner: runner) let malformed = directory.appendingPathComponent(invalidName) try SecureFileIO.writeNewFile(Data("This is deliberately not a ZIP archive.\n".utf8), to: malformed, mode: 0o600) - let sources = try [validName, invalidName].map { name in + let sources = try [validName, validBName, invalidName].map { name in SourceDigest(path: name, sha256: try digest(directory.appendingPathComponent(name))) } let inventory = try await inventory(identifier: identifier, runner: runner) - guard !inventory.contains(fixtureBundle) else { - throw failure("The new simulator already contains the acceptance package; installation cancellation cannot be measured.", cause: nil) + guard !inventory.contains(fixtureBundle), !inventory.contains(fixtureBBundle) else { + throw failure("The new simulator already contains an acceptance package; installation cancellation cannot be measured.", cause: nil) } manifest = Manifest(owner: owner, simulator: identifier, sources: sources, inventory: inventory, existingPreparations: existingPreparations) try save(manifest, directory: directory) @@ -102,6 +105,7 @@ struct PackageInspectionFixtures { "IDT_PACKAGE_FIXTURE_SIMULATOR": identifier, "IDT_PACKAGE_FIXTURE_OWNER": owner, "IDT_PACKAGE_FIXTURE_VALID": app.path, + "IDT_PACKAGE_FIXTURE_VALID_B": appB.path, "IDT_PACKAGE_FIXTURE_INVALID": malformed.path, ] guard environment.values.allSatisfy({ !$0.contains("\n") && !$0.contains("\r") }) else { @@ -109,13 +113,13 @@ struct PackageInspectionFixtures { } let lines = environment.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)\n" }.joined() try SecureFileIO.writeNewFile(Data(lines.utf8), to: directory.appendingPathComponent("environment.env"), mode: 0o600) - print("[package fixtures] One owner-matched booted simulator, a genuine signed simulator app, malformed IPA and source/inventory baselines are ready.") + print("[package fixtures] One owner-matched booted simulator, two distinct genuine signed simulator apps, malformed IPA and source/inventory baselines are ready.") } static func verifyCleanup(directory: URL, receipt: URL, runner: ProcessCommandRunner) async throws { let manifest = try load(directory: directory) - guard let sources = manifest.sources, sources.count == 2, - Set(sources.map(\.path)) == Set([validName, invalidName]), + guard let sources = manifest.sources, sources.count == 3, + Set(sources.map(\.path)) == Set([validName, validBName, invalidName]), let expectedInventory = manifest.inventory, let identifier = manifest.simulator else { throw failure("Package acceptance verification requires a completely prepared ownership registry.", cause: nil) } @@ -133,8 +137,8 @@ struct PackageInspectionFixtures { throw failure("The native cleanup receipt has an invalid schema.", cause: nil) } let recorded = try JSONDecoder().decode(CleanupReceipt.self, from: receiptData).preparedDirectories - guard recorded.count == 2, Set(recorded).count == 2 else { - throw failure("The native receipt must contain distinct actual preparation paths from the first valid choice and recovered valid choice.", cause: nil) + guard recorded.count == 4, Set(recorded).count == 4 else { + throw failure("The native receipt must contain four distinct actual preparation paths from initial inspection, recovery, late completion and the final current package.", cause: nil) } let temporary = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).resolvingSymlinksInPath() for path in recorded { @@ -149,13 +153,13 @@ struct PackageInspectionFixtures { } var metadata = stat() guard lstat(prepared.path, &metadata) != 0 else { - throw failure("A recorded private preparation remains after malformed re-selection or normal Quit.", cause: nil) + throw failure("A recorded private preparation remains after re-selection, stale completion or normal Quit.", cause: nil) } guard errno == ENOENT else { throw failure("A recorded private preparation could not be checked for absence.", cause: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) } } - print("[package fixtures] Independent verification passed: both recorded private preparations are absent, source hashes are unchanged and the owned simulator inventory is unchanged.") + print("[package fixtures] Independent verification passed: all four recorded private preparations are absent, all three source hashes are unchanged and the owned simulator inventory is unchanged.") } static func cleanup(directory: URL, runner: ProcessCommandRunner) async throws { @@ -188,10 +192,10 @@ struct PackageInspectionFixtures { print("[package fixtures] Fresh discovery verified the owned simulator UUID and exact owner name absent; the owned fixture directory was removed.") } - static func fixture(directory: URL, runner: ProcessCommandRunner) async throws -> URL { - let app = directory.appendingPathComponent(validName, isDirectory: true) + static func fixture(directory: URL, name: String, bundle: String, displayName: String, runner: ProcessCommandRunner) async throws -> URL { + let app = directory.appendingPathComponent(name, isDirectory: true) try SecureFileIO.createNewPrivateDirectory(at: app) - let source = directory.appendingPathComponent("Fixture.c") + let source = directory.appendingPathComponent("\(app.deletingPathExtension().lastPathComponent).c") try SecureFileIO.writeNewFile(Data("int main(void) { return 0; }\n".utf8), to: source, mode: 0o600) #if arch(arm64) let triple = "arm64-apple-ios17.0-simulator" @@ -199,8 +203,8 @@ struct PackageInspectionFixtures { let triple = "x86_64-apple-ios17.0-simulator" #endif _ = try await command(["--sdk", "iphonesimulator", "clang", "-target", triple, "-o", app.appendingPathComponent("Fixture").path, source.path], timeout: 300, runner: runner) - let info: PlistValue = ["CFBundleIdentifier": .string(fixtureBundle), "CFBundleExecutable": "Fixture", "CFBundleName": "Package Fixture", - "CFBundleDisplayName": "Package Fixture", "CFBundlePackageType": "APPL", "CFBundleVersion": "1", + let info: PlistValue = ["CFBundleIdentifier": .string(bundle), "CFBundleExecutable": "Fixture", "CFBundleName": .string(displayName), + "CFBundleDisplayName": .string(displayName), "CFBundlePackageType": "APPL", "CFBundleVersion": "1", "CFBundleShortVersionString": "1.0", "MinimumOSVersion": "17.0", "CFBundleSupportedPlatforms": ["iPhoneSimulator"], "UIDeviceFamily": [1, 2]] try SecureFileIO.writeNewFile(info.encoded(format: .xml), to: app.appendingPathComponent("Info.plist"), mode: 0o600) let request = CommandRequest(executable: try AppleTool.codesign.locate(), arguments: ["--sign", "-", app.path], timeout: 60, displayName: "sign package fixture") From 13d46dbd8776f93099f74a4a36f1d374e8e5cb71 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:29:43 -0800 Subject: [PATCH 10/20] hideouts/package-inspection: resolve native path field directly Use the actual unique application-level PathTextField observed in the failed hosted chooser. Preserve the five-second existence and hittability condition and all single-action path assertions. Headless app/UI build, signature verification and fixed diff review pass; fresh native acceptance remains required. --- App/UITests/PackageInspectionUITests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index 8ec7cf9..88ce588 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -222,8 +222,10 @@ final class PackageInspectionUITests: XCTestCase { panel.typeKey("g", modifierFlags: [.command, .shift]) let goTo = panel.descendants(matching: .any)["GoToWindow"] wait(goTo, predicate: "exists == true", timeout: 5) - let field = goTo.textFields["PathTextField"] + let fields = application!.textFields.matching(identifier: "PathTextField") + let field = fields.element wait(field, predicate: "exists == true AND hittable == true", timeout: 5) + XCTAssertEqual(fields.count, 1, "The native Go to Folder field must resolve uniquely from the application") field.click() field.typeKey("a", modifierFlags: [.command]) field.typeText(path) From a80a2011e550c3738ad1f779437ec466c49a091d Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:45:02 -0800 Subject: [PATCH 11/20] hideouts/package-inspection: verify Quit with an incomplete native reader Add explicit Debug acceptance that retains a genuine SecStaticCode handle before unchanged validation on the dedicated worker. Extend native recovery through the existing eight-second Quit timeout, cancellation, blocked re-selection, real validation and cleanup, recovery and normal Quit. Host verification requires six actual preparation paths and unchanged source hashes and simulator inventory. Local strict parallel tests, native signature integration, App/UI compilation, signatures, helper compilation and independent review pass. Hosted native acceptance and exact-candidate Swift CodeQL remain pending; the lease does not prove C-call overlap. --- .github/workflows/ci.yml | 2 +- App/UITests/PackageInspectionUITests.swift | 136 ++++++++++++--- .../Model/FeatureModels.swift | 46 +++++- App/iOSDeveloperToolkit/Views/AppsViews.swift | 30 ++++ .../iOSDeveloperToolkitApp.swift | 2 + .../PackageInspectionReaderAcceptance.swift | 155 ++++++++++++++++++ .../IPA/PreparedAppPackage.swift | 27 ++- .../IPA/SigningInspection.swift | 30 ++++ .../AppPackagePreparationTests.swift | 66 ++++++++ scripts/PackageInspectionFixtures.swift | 8 +- 10 files changed, 470 insertions(+), 32 deletions(-) create mode 100644 Sources/ToolkitFeatures/IPA/PackageInspectionReaderAcceptance.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2815d16..749fd7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: for key in sorted(values): output.write("TEST_RUNNER_" + key + "=" + values[key] + "\n") PY - - name: Hosted package recovery, late completion and normal Quit acceptance + - name: Hosted package recovery, late completion and incomplete reader Quit acceptance env: TEST_RUNNER_IDT_PACKAGE_UI_TESTS: "1" run: | diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index 88ce588..3a51855 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -56,27 +56,7 @@ final class PackageInspectionUITests: XCTestCase { let fixtures = try Fixtures.load(environment: ProcessInfo.processInfo.environment) let app = XCUIApplication() application = app - app.launchArguments = ["-ui-testing", "YES", "-demo-mode", "NO", "-workspace", "installApp", - "-package-acceptance-simulator", fixtures.simulator, - "-package-acceptance-owner", fixtures.owner, - "-ApplePersistenceIgnoreState", "YES"] - app.launchEnvironment["IDT_RESTORE_HELPERS"] = "" - app.launch() - app.activate() - XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) - XCTAssertFalse(element("demo-banner").exists) - let readiness = element("package-target-readiness") - attachReadiness("package-discovery-before-wait") - wait(readiness, predicate: "exists == true AND value == 'ready'", timeout: 75) - attachReadiness("package-owned-target-ready") - let picker = element("target-picker") - wait(picker, predicate: "exists == true AND hittable == true", timeout: 10) - picker.click() - let simulator = element("target-simulator-\(fixtures.simulator)") - wait(simulator, predicate: "exists == true AND hittable == true", timeout: 10) - simulator.click() - wait(simulator, predicate: "exists == false", timeout: 10) - XCTAssertFalse(element("install-package").exists) + launchOwnedApplication(app, fixtures: fixtures) try choosePackage(fixtures.valid) let first = try requirePreparation(original: fixtures.valid, activeOperations: 0) @@ -208,10 +188,122 @@ final class PackageInspectionUITests: XCTestCase { try requireAbsent(recovered.directory) try requireAbsent(late.directory) try requireAbsent(current.directory) - let data = try JSONEncoder().encode(CleanupReceipt(preparedDirectories: [first.directory, recovered.directory, late.directory, current.directory])) + + // A new launch owns a real unfinished worker and native code handle, before validation. + // Holding a completed result above does not establish this reader-lifetime behavior. + launchOwnedApplication(app, fixtures: fixtures) + let armReader = element("package-reader-arm") + reveal(armReader) + XCTAssertTrue(armReader.isEnabled) + armReader.click() + wait(element("package-reader-phase"), predicate: "value == 'armed'", timeout: 5) + try choosePackage(fixtures.valid) + wait(element("package-reader-phase"), predicate: "value == 'native-handle-held-before-validation'", timeout: 120) + let readerDirectory = try value("package-reader-directory") + XCTAssertEqual(try value("package-reader-original-url"), fixtures.valid) + XCTAssertEqual(try value("package-reader-package-url"), URL(fileURLWithPath: readerDirectory).appendingPathComponent("ValidSimulator.app").path) + try requirePrivateDirectory(readerDirectory) + try requirePackageCopy(directory: readerDirectory, original: fixtures.valid) + XCTAssertEqual(try value("package-reader-cancelled"), "false") + XCTAssertEqual(try value("package-reader-signature-status"), "pending") + XCTAssertEqual(try value("package-reader-choice-returned"), "false") + XCTAssertEqual(try value("package-is-terminating"), "false") + XCTAssertEqual(try value("package-prepared-id"), "none") + wait(element("package-active-operations"), predicate: "value == '1'", timeout: 10) + wait(element("install-package"), predicate: "exists == true AND enabled == false", timeout: 10) + attachState("package-native-handle-held-before-validation") + + app.typeKey("q", modifierFlags: [.command]) + let quitAnyway = element("quit-operations-confirm") + wait(quitAnyway, predicate: "exists == true AND hittable == true", timeout: 10) + attach(app.debugDescription, name: "package-quit-operations-alert") + quitAnyway.click() + // The production deadline remains eight seconds. It must refuse this incomplete Quit. + wait(element("error-message"), predicate: "exists == true", timeout: 20) + XCTAssertEqual(try value("error-message"), "Quit cleanup is still running after eight seconds. The app will remain open.") + XCTAssertEqual(try value("error-recovery"), "Wait for the active operation to finish, then try quitting again.") + XCTAssertEqual(app.state, .runningForeground) + XCTAssertEqual(try value("package-is-terminating"), "true") + XCTAssertEqual(try value("package-reader-phase"), "native-handle-held-before-validation") + XCTAssertEqual(try value("package-reader-cancelled"), "true") + XCTAssertEqual(try value("package-reader-choice-returned"), "false") + XCTAssertEqual(try value("package-active-operations"), "1") + try requirePrivateDirectory(readerDirectory) + try requirePackageCopy(directory: readerDirectory, original: fixtures.valid) + attachState("package-quit-timeout-reader-owned") + wait(dismiss, predicate: "exists == true AND hittable == true", timeout: 10) + dismiss.click() + wait(element("error-message"), predicate: "exists == false", timeout: 10) + + // The real chooser must refuse a new package while the old Quit still owns cleanup. + try choosePackage(fixtures.valid) + wait(element("error-message"), predicate: "exists == true", timeout: 10) + XCTAssertEqual(try value("error-message"), "The app is quitting. Cancel quitting before choosing another package.") + XCTAssertEqual(try value("package-is-terminating"), "true") + XCTAssertEqual(try value("package-reader-choice-returned"), "false") + XCTAssertEqual(try value("package-active-operations"), "1") + XCTAssertEqual(try value("package-reader-directory"), readerDirectory) + try requirePrivateDirectory(readerDirectory) + attachState("package-choice-denied-during-reader-cleanup") + wait(dismiss, predicate: "exists == true AND hittable == true", timeout: 10) + dismiss.click() + wait(element("error-message"), predicate: "exists == false", timeout: 10) + + let releaseReader = element("package-reader-release") + reveal(releaseReader) + XCTAssertTrue(releaseReader.isEnabled) + releaseReader.click() + wait(element("package-reader-phase"), predicate: "value == 'validation-returned'", timeout: 120) + wait(element("package-reader-choice-returned"), predicate: "value == 'true'", timeout: 10) + wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) + wait(element("package-is-terminating"), predicate: "value == 'false'", timeout: 10) + XCTAssertEqual(try value("package-reader-cancelled"), "true") + XCTAssertEqual(try value("package-reader-signature-status"), "valid") + XCTAssertEqual(try value("package-prepared-id"), "none") + XCTAssertEqual(try value("package-prepared-directory"), "none") + XCTAssertEqual(try value("package-original-url"), "none") + XCTAssertFalse(element("error-message").exists) + XCTAssertFalse(element("install-package").exists) + try requireAbsent(readerDirectory) + attachState("package-reader-returned-and-cleaned") + + try choosePackage(fixtures.valid) + let readerRecovery = try requirePreparation(original: fixtures.valid, activeOperations: 0) + XCTAssertNotEqual(readerRecovery.directory, readerDirectory) + XCTAssertEqual(try value("package-is-terminating"), "false") + attachState("package-recovered-after-quit-timeout") + app.typeKey("q", modifierFlags: [.command]) + XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "Normal Quit must recover after the incomplete reader finishes") + try requireAbsent(readerDirectory) + try requireAbsent(readerRecovery.directory) + let data = try JSONEncoder().encode(CleanupReceipt(preparedDirectories: [first.directory, recovered.directory, late.directory, current.directory, readerDirectory, readerRecovery.directory])) attach(String(decoding: data, as: UTF8.self), name: "package-cleanup-receipt") } + private func launchOwnedApplication(_ app: XCUIApplication, fixtures: Fixtures) { + app.launchArguments = ["-ui-testing", "YES", "-demo-mode", "NO", "-workspace", "installApp", + "-package-acceptance-simulator", fixtures.simulator, + "-package-acceptance-owner", fixtures.owner, + "-ApplePersistenceIgnoreState", "YES"] + app.launchEnvironment["IDT_RESTORE_HELPERS"] = "" + app.launch() + app.activate() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) + XCTAssertFalse(element("demo-banner").exists) + let readiness = element("package-target-readiness") + attachReadiness("package-discovery-before-wait") + wait(readiness, predicate: "exists == true AND value == 'ready'", timeout: 75) + attachReadiness("package-owned-target-ready") + let picker = element("target-picker") + wait(picker, predicate: "exists == true AND hittable == true", timeout: 10) + picker.click() + let simulator = element("target-simulator-\(fixtures.simulator)") + wait(simulator, predicate: "exists == true AND hittable == true", timeout: 10) + simulator.click() + wait(simulator, predicate: "exists == false", timeout: 10) + XCTAssertFalse(element("install-package").exists) + } + /// Uses the identifiers recorded from the real AppKit panel on the hosted Mac. private func choosePackage(_ path: String) throws { let choose = element("choose-package") diff --git a/App/iOSDeveloperToolkit/Model/FeatureModels.swift b/App/iOSDeveloperToolkit/Model/FeatureModels.swift index 3bc1b28..a2d933e 100644 --- a/App/iOSDeveloperToolkit/Model/FeatureModels.swift +++ b/App/iOSDeveloperToolkit/Model/FeatureModels.swift @@ -144,6 +144,34 @@ final class InstallModel { private(set) var heldPreparationWasCancelled: Bool? private var completionContinuation: CheckedContinuation? private var completionDeadline: Task? + private var readerAcceptance: PackageInspectionReaderAcceptance? + private var readerAcceptanceID: UUID? + private(set) var readerSnapshot: PackageInspectionReaderAcceptance.Snapshot? + private(set) var readerChoiceReturned = false + + func armReaderAcceptance(app: AppModel) throws(ToolkitError) { + guard app.packageAcceptanceTarget != nil, readerAcceptance == nil else { + throw ToolkitError(.invalidInput, message: "The native package reader acceptance gate cannot be armed.", recovery: "Launch a fresh owned-simulator package acceptance session before arming its one-shot reader gate.", technicalDetail: nil) + } + let identifier = UUID() + readerAcceptanceID = identifier + let reader = PackageInspectionReaderAcceptance { [weak self] snapshot in + Task { @MainActor in + guard let self, self.readerAcceptanceID == identifier, + snapshot.revision > (self.readerSnapshot?.revision ?? 0) else { return } + self.readerSnapshot = snapshot + } + } + readerAcceptance = reader + readerSnapshot = reader.snapshot + } + + func releaseReaderAcceptance(app: AppModel) throws { + guard app.packageAcceptanceTarget != nil, let readerAcceptance else { + throw ToolkitError(.invalidInput, message: "There is no native package reader to release.", recovery: "Arm the owned-simulator reader acceptance gate and wait for its native handle.", technicalDetail: nil) + } + try readerAcceptance.release() + } func armCompletionAcceptance(app: AppModel) throws(ToolkitError) { guard app.packageAcceptanceTarget != nil, completionAcceptanceState == .idle else { @@ -169,6 +197,12 @@ final class InstallModel { if completionAcceptanceState == .released, url == heldPreparationOriginalURL { completionAcceptanceState = .delivered } + if let readerAcceptance { + let snapshot = readerAcceptance.snapshot + if snapshot.phase == .validationReturned, snapshot.originalURL == url { + readerChoiceReturned = true + } + } } /// Holds a genuine completed preparation outside the serial Security.framework executor. @@ -249,10 +283,20 @@ final class InstallModel { activePreparations -= 1 resumeCleanupIfIdle() } +#if DEBUG + let reader = readerAcceptance?.snapshot.phase == .armed ? readerAcceptance : nil +#endif let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path], onStart: { self.inspectionOperation = $0 }) { _ in - let package = try await PreparedAppPackage.prepareAsync(url) #if DEBUG + let package: PreparedAppPackage + if let reader { + package = try await PreparedAppPackage.prepareAsyncForReaderAcceptance(url, reader: reader) + } else { + package = try await PreparedAppPackage.prepareAsync(url) + } await self.holdCompletedPreparationIfArmed(package, app: app) +#else + let package = try await PreparedAppPackage.prepareAsync(url) #endif return package } diff --git a/App/iOSDeveloperToolkit/Views/AppsViews.swift b/App/iOSDeveloperToolkit/Views/AppsViews.swift index d2a60d0..99a6b3a 100644 --- a/App/iOSDeveloperToolkit/Views/AppsViews.swift +++ b/App/iOSDeveloperToolkit/Views/AppsViews.swift @@ -163,6 +163,36 @@ struct InstallAppView: View { .disabled(install.completionAcceptanceState != .held) .accessibilityIdentifier("package-completion-release") } + Text(model.isTerminating ? "true" : "false") + .accessibilityIdentifier("package-is-terminating") + Text(install.readerSnapshot?.phase.rawValue ?? "idle") + .accessibilityIdentifier("package-reader-phase") + Text(install.readerSnapshot?.directory?.path ?? "none") + .accessibilityIdentifier("package-reader-directory") + Text(install.readerSnapshot?.packageURL?.path ?? "none") + .accessibilityIdentifier("package-reader-package-url") + Text(install.readerSnapshot?.originalURL?.path ?? "none") + .accessibilityIdentifier("package-reader-original-url") + Text(install.readerSnapshot?.cancellationRequested == true ? "true" : "false") + .accessibilityIdentifier("package-reader-cancelled") + Text(install.readerSnapshot?.signatureStatus?.rawValue ?? "pending") + .accessibilityIdentifier("package-reader-signature-status") + Text(install.readerChoiceReturned ? "true" : "false") + .accessibilityIdentifier("package-reader-choice-returned") + HStack { + Button("Arm native reader acceptance") { + do { try install.armReaderAcceptance(app: model) } + catch { model.present(error) } + } + .disabled(install.readerSnapshot != nil) + .accessibilityIdentifier("package-reader-arm") + Button("Release native reader") { + do { try install.releaseReaderAcceptance(app: model) } + catch { model.present(error) } + } + .disabled(install.readerSnapshot?.phase != .nativeHandleHeldBeforeValidation) + .accessibilityIdentifier("package-reader-release") + } } .font(.caption.monospaced()) .lineLimit(1) diff --git a/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift b/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift index 96f67df..b52c816 100644 --- a/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift +++ b/App/iOSDeveloperToolkit/iOSDeveloperToolkitApp.swift @@ -83,6 +83,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } alert.addButton(withTitle: "Quit Anyway") alert.addButton(withTitle: "Cancel") + alert.buttons[0].setAccessibilityIdentifier("quit-operations-confirm") + alert.buttons[1].setAccessibilityIdentifier("quit-operations-cancel") guard alert.runModal() == .alertFirstButtonReturn else { return .terminateCancel } } // A modal alert runs a nested event loop. Writing may have started while it was open. diff --git a/Sources/ToolkitFeatures/IPA/PackageInspectionReaderAcceptance.swift b/Sources/ToolkitFeatures/IPA/PackageInspectionReaderAcceptance.swift new file mode 100644 index 0000000..2bb0ece --- /dev/null +++ b/Sources/ToolkitFeatures/IPA/PackageInspectionReaderAcceptance.swift @@ -0,0 +1,155 @@ +#if DEBUG +import Dispatch +import Foundation +import ToolkitCore + +/// Connects explicit native acceptance controls to a genuine inspection worker. Only its +/// Dispatch thread waits; the real native code handle and incomplete copy stay owned until +/// release or finite expiry. This observes a handle before validation, not an active C call. +public struct PackageInspectionReaderAcceptance: Sendable { + public enum Phase: String, Sendable { + case armed + case nativeHandleHeldBeforeValidation = "native-handle-held-before-validation" + case released + case validationReturned = "validation-returned" + case expired + case aborted + } + + public struct Snapshot: Sendable { + public let revision: UInt64 + public let phase: Phase + public let originalURL: URL? + public let packageURL: URL? + public let directory: URL? + public let cancellationRequested: Bool + public let signatureStatus: CodeSignatureSummary.Status? + } + + private struct State { + var revision: UInt64 = 0 + var phase: Phase = .armed + var originalURL: URL? + var packageURL: URL? + var cancellationRequested = false + var signatureStatus: CodeSignatureSummary.Status? + + var snapshot: Snapshot { + Snapshot(revision: revision, phase: phase, originalURL: originalURL, + packageURL: packageURL, directory: packageURL?.deletingLastPathComponent(), + cancellationRequested: cancellationRequested, signatureStatus: signatureStatus) + } + } + + private let state: LockedValue + private let barrier: DispatchSemaphore + private let observer: @Sendable (Snapshot) -> Void + + public init(observer: @escaping @Sendable (Snapshot) -> Void) { + state = LockedValue(State()) + barrier = DispatchSemaphore(value: 0) + self.observer = observer + } + + public var snapshot: Snapshot { state.current.snapshot } + + public func release() throws { + let update = try state.withLock { value -> Snapshot in + guard value.phase == .nativeHandleHeldBeforeValidation else { + throw ToolkitError(.invalidInput, message: "No native package reader is waiting for release.", recovery: "Wait for the explicit owned-simulator acceptance reader to open its real code handle.", technicalDetail: nil) + } + value.phase = .released + value.revision += 1 + return value.snapshot + } + observer(update) + barrier.signal() + } + + func begin(source: URL) throws { + let update = try state.withLock { value -> Snapshot in + if value.phase == .aborted { throw CancellationError() } + guard value.phase == .armed, value.originalURL == nil else { + throw ToolkitError(.invalidInput, message: "The native package reader acceptance gate has already been used.", recovery: "Launch a fresh owned-simulator acceptance session.", technicalDetail: nil) + } + value.originalURL = source.standardizedFileURL + value.revision += 1 + return value.snapshot + } + observer(update) + } + + /// Failure teardown must close the armed-to-held race before a structured task group + /// drains its worker. A later real native handle cannot park after this atomic close. + func finishAfterFailure() { + let update = state.withLock { value -> (snapshot: Snapshot, signal: Bool) in + switch value.phase { + case .armed: + value.phase = .aborted + value.revision += 1 + return (value.snapshot, false) + case .nativeHandleHeldBeforeValidation: + value.phase = .released + value.revision += 1 + return (value.snapshot, true) + case .released, .validationReturned, .expired, .aborted: + return (value.snapshot, false) + } + } + observer(update.snapshot) + if update.signal { barrier.signal() } + } + + /// Called only after the executor has set its actual cancellation token. + func cancellationRequested() { + let update = state.withLock { value -> Snapshot in + value.cancellationRequested = true + value.revision += 1 + return value.snapshot + } + observer(update) + } + + func holdNativeHandle(bundle: URL) throws { + let update = try state.withLock { value -> Snapshot in + if value.phase == .aborted { throw CancellationError() } + guard value.phase == .armed, value.originalURL != nil else { + throw ToolkitError(.internalInconsistency, message: "The native package reader acceptance gate is not armed.", recovery: "Inspect the acceptance launch and choose the genuine fixture again.", technicalDetail: nil) + } + value.packageURL = bundle + value.phase = .nativeHandleHeldBeforeValidation + value.revision += 1 + return value.snapshot + } + observer(update) + + let outcome = barrier.wait(timeout: .now() + 180) + let final = try state.withLock { value -> Snapshot in + // Release and expiry race under the same lock. A release that won remains valid + // even if its signal reached this thread at the wait's exact deadline. + if outcome == .timedOut, value.phase == .nativeHandleHeldBeforeValidation { + value.phase = .expired + value.revision += 1 + } + guard value.phase == .released || value.phase == .expired else { + throw ToolkitError(.internalInconsistency, message: "The native package reader ended in an invalid acceptance phase.", recovery: "Inspect the native acceptance failure before retrying.", technicalDetail: nil) + } + return value.snapshot + } + if final.phase == .expired { + observer(final) + throw ToolkitError(.timedOut, message: "The native package reader was not released within 180 seconds.", recovery: "Inspect the native acceptance failure; the worker will release its native handle and remove the incomplete private copy.", technicalDetail: nil) + } + } + + func validationReturned(status: CodeSignatureSummary.Status) { + let update = state.withLock { value -> Snapshot in + value.signatureStatus = status + value.phase = .validationReturned + value.revision += 1 + return value.snapshot + } + observer(update) + } +} +#endif diff --git a/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift index 03d23f1..6d78f94 100644 --- a/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift +++ b/Sources/ToolkitFeatures/IPA/PreparedAppPackage.swift @@ -25,8 +25,27 @@ public struct PreparedAppPackage: Sendable { /// until native validation finishes, then removes its private copy before receiving cancellation. public static func prepareAsync(_ source: URL) async throws -> PreparedAppPackage { let package = try await PackageInspectionExecutor.run { checkCancellation in - try prepare(source, checkCancellation: checkCancellation) + try prepare(source, checkCancellation: checkCancellation, inspectSignature: { CodeSignatureInspector.inspect(bundle: $0) }) } + return try finishPreparation(package) + } + +#if DEBUG + /// Explicit acceptance keeps a genuine native code handle alive before validation. The + /// ordinary signature verifier and subsequent cancellation/ownership cleanup still run. + public static func prepareAsyncForReaderAcceptance(_ source: URL, reader: PackageInspectionReaderAcceptance) async throws -> PreparedAppPackage { + guard source.isFileURL, source.pathExtension.lowercased() == "app" else { + throw ToolkitError.invalidInput("Native reader acceptance requires a genuine .app bundle.") + } + try reader.begin(source: source) + let package = try await PackageInspectionExecutor.runForReaderAcceptance(reader: reader) { checkCancellation in + try prepare(source, checkCancellation: checkCancellation, inspectSignature: { try CodeSignatureInspector.inspectForReaderAcceptance(bundle: $0, reader: reader) }) + } + return try finishPreparation(package) + } +#endif + + private static func finishPreparation(_ package: PreparedAppPackage) throws -> PreparedAppPackage { guard !Task.isCancelled else { do { try package.remove() } catch { @@ -39,10 +58,10 @@ public struct PreparedAppPackage: Sendable { /// Blocking entry point for synchronous callers. Swift tasks should use `prepareAsync`. public static func prepare(_ source: URL) throws -> PreparedAppPackage { - try prepare(source, checkCancellation: { try Task.checkCancellation() }) + try prepare(source, checkCancellation: { try Task.checkCancellation() }, inspectSignature: { CodeSignatureInspector.inspect(bundle: $0) }) } - private static func prepare(_ source: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck) throws -> PreparedAppPackage { + private static func prepare(_ source: URL, checkCancellation: PackageInspectionExecutor.CancellationCheck, inspectSignature: @Sendable (URL) throws -> CodeSignatureSummary) throws -> PreparedAppPackage { try checkCancellation() guard source.isFileURL, ["ipa", "app"].contains(source.pathExtension.lowercased()) else { throw ToolkitError.invalidInput("Choose an .ipa package or an iOS .app bundle.") @@ -72,7 +91,7 @@ public struct PreparedAppPackage: Sendable { inspection = nil appName = info.name platforms = info.platforms - signature = CodeSignatureInspector.inspect(bundle: copy) + signature = try inspectSignature(copy) } guard try packageIdentity(copy, checkCancellation: checkCancellation) == identity, try packageIdentity(original, checkCancellation: checkCancellation) == identity else { throw ToolkitError(.invalidInput, message: "The package changed during inspection.", recovery: "Choose the completed package again.", technicalDetail: original.path) diff --git a/Sources/ToolkitFeatures/IPA/SigningInspection.swift b/Sources/ToolkitFeatures/IPA/SigningInspection.swift index b049479..2617614 100644 --- a/Sources/ToolkitFeatures/IPA/SigningInspection.swift +++ b/Sources/ToolkitFeatures/IPA/SigningInspection.swift @@ -142,6 +142,25 @@ public enum CodeSignatureInspector { guard SecStaticCodeCreateWithPath(url as CFURL, [], &codeReference) == errSecSuccess, let code = codeReference else { return CodeSignatureSummary(status: .missing, identifier: nil, teamIdentifier: nil, authorities: [], detail: "The app bundle could not be opened for signature checking.") } + return verify(code) + } + +#if DEBUG + static func inspectForReaderAcceptance(bundle url: URL, reader: PackageInspectionReaderAcceptance) throws -> CodeSignatureSummary { + var codeReference: SecStaticCode? + guard SecStaticCodeCreateWithPath(url as CFURL, [], &codeReference) == errSecSuccess, let code = codeReference else { + throw ToolkitError(.invalidInput, message: "The acceptance app bundle could not be opened for native signature checking.", recovery: "Choose the genuine signed simulator fixture again.", technicalDetail: nil) + } + return try withExtendedLifetime(code) { + try reader.holdNativeHandle(bundle: url) + let summary = verify(code) + reader.validationReturned(status: summary.status) + return summary + } + } +#endif + + private static func verify(_ code: SecStaticCode) -> CodeSignatureSummary { var errors: Unmanaged? let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSStrictValidate | kSecCSCheckNestedCode) let status = SecStaticCodeCheckValidityWithErrors(code, flags, nil, &errors) @@ -174,6 +193,16 @@ enum PackageInspectionExecutor { private static let queue = DispatchQueue(label: "io.hideouts.package-inspection", qos: .userInitiated) static func run(_ operation: @escaping @Sendable (CancellationCheck) throws -> Value) async throws -> Value { + try await perform(onCancellation: {}, operation) + } + +#if DEBUG + static func runForReaderAcceptance(reader: PackageInspectionReaderAcceptance, _ operation: @escaping @Sendable (CancellationCheck) throws -> Value) async throws -> Value { + try await perform(onCancellation: { reader.cancellationRequested() }, operation) + } +#endif + + private static func perform(onCancellation: @escaping @Sendable () -> Void, _ operation: @escaping @Sendable (CancellationCheck) throws -> Value) async throws -> Value { let cancelled = LockedValue(false) let checkCancellation: CancellationCheck = { if cancelled.current { throw CancellationError() } @@ -190,6 +219,7 @@ enum PackageInspectionExecutor { } } onCancel: { cancelled.withLock { $0 = true } + onCancellation() } } } diff --git a/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift index ecb63e4..f39d014 100644 --- a/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift +++ b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift @@ -53,6 +53,72 @@ struct AppPackagePreparationTests { #expect(FileManager.default.fileExists(atPath: app.appendingPathComponent("Demo").path)) } +#if DEBUG + @Test(.timeLimit(.minutes(1)), .enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/codesign"))) + func runningNativeReaderRetainsItsCopyUntilValidationAndCancellationFinish() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-native-reader") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "ReaderDemo", platform: "iPhoneSimulator") + try await sign(app, in: root) + let original = try Data(contentsOf: app.appendingPathComponent("ReaderDemo")) + let (events, continuation) = AsyncStream.makeStream() + let reader = PackageInspectionReaderAcceptance(observer: { continuation.yield($0) }) + try await withThrowingTaskGroup(of: PreparedAppPackage.self) { group in + group.addTask { + defer { continuation.finish() } + return try await PreparedAppPackage.prepareAsyncForReaderAcceptance(app, reader: reader) + } + do { + let held = try await withTimeout(10, operation: "observe a genuine native reader") { + for await snapshot in events { + if snapshot.phase == .nativeHandleHeldBeforeValidation { return snapshot } + } + throw ToolkitError(.internalInconsistency, message: "The native reader ended before opening its code handle.", recovery: nil, technicalDetail: nil) + } + let directory = try #require(held.directory) + let copy = try #require(held.packageURL) + #expect(held.originalURL == app) + var metadata = stat() + #expect(lstat(directory.path, &metadata) == 0) + #expect(metadata.st_mode & 0o777 == 0o700) + #expect(try Data(contentsOf: copy.appendingPathComponent("ReaderDemo")) == original) + + group.cancelAll() + let cancelled = try await withTimeout(10, operation: "observe native reader cancellation") { + for await snapshot in events { + if snapshot.cancellationRequested { return snapshot } + } + throw ToolkitError(.internalInconsistency, message: "The native reader ended before observing cancellation.", recovery: nil, technicalDetail: nil) + } + #expect(cancelled.phase == .nativeHandleHeldBeforeValidation) + #expect(lstat(directory.path, &metadata) == 0) + try reader.release() + var observedCancellation = false + do { + if let unexpected = try await group.next() { + try unexpected.remove() + Issue.record("A cancelled native reader returned an installable preparation.") + } + } catch is CancellationError { + observedCancellation = true + } + #expect(observedCancellation) + #expect(reader.snapshot.phase == .validationReturned) + #expect(reader.snapshot.signatureStatus == .valid) + let removal = lstat(directory.path, &metadata) + let removalError = errno + #expect(removal == -1) + #expect(removalError == ENOENT) + #expect(try Data(contentsOf: app.appendingPathComponent("ReaderDemo")) == original) + } catch { + group.cancelAll() + reader.finishAfterFailure() + throw error + } + } + } +#endif + private enum ChangedLocation: String, CaseIterable { case original, prepared } diff --git a/scripts/PackageInspectionFixtures.swift b/scripts/PackageInspectionFixtures.swift index 301fb34..f50f58e 100644 --- a/scripts/PackageInspectionFixtures.swift +++ b/scripts/PackageInspectionFixtures.swift @@ -137,8 +137,8 @@ struct PackageInspectionFixtures { throw failure("The native cleanup receipt has an invalid schema.", cause: nil) } let recorded = try JSONDecoder().decode(CleanupReceipt.self, from: receiptData).preparedDirectories - guard recorded.count == 4, Set(recorded).count == 4 else { - throw failure("The native receipt must contain four distinct actual preparation paths from initial inspection, recovery, late completion and the final current package.", cause: nil) + guard recorded.count == 6, Set(recorded).count == 6 else { + throw failure("The native receipt must contain six distinct actual preparation paths from initial inspection, recovery, late completion, the current package, the incomplete reader and reader recovery.", cause: nil) } let temporary = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).resolvingSymlinksInPath() for path in recorded { @@ -153,13 +153,13 @@ struct PackageInspectionFixtures { } var metadata = stat() guard lstat(prepared.path, &metadata) != 0 else { - throw failure("A recorded private preparation remains after re-selection, stale completion or normal Quit.", cause: nil) + throw failure("A recorded private preparation remains after re-selection, stale completion, reader cancellation or normal Quit.", cause: nil) } guard errno == ENOENT else { throw failure("A recorded private preparation could not be checked for absence.", cause: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) } } - print("[package fixtures] Independent verification passed: all four recorded private preparations are absent, all three source hashes are unchanged and the owned simulator inventory is unchanged.") + print("[package fixtures] Independent verification passed: all six recorded private preparations are absent, all three source hashes are unchanged and the owned simulator inventory is unchanged.") } static func cleanup(directory: URL, runner: ProcessCommandRunner) async throws { From 45dd5adf05fd8c697b9ddf913fb6047a41df55a6 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:07:51 -0800 Subject: [PATCH 12/20] hideouts/package-inspection: resolve the current native Open button Use the observed unique application-level OKButton instead of the obsolete panel ancestry exposed by the failed hosted chooser. Preserve the ten-second existence/enabled/hittable check and single Open action. Headless App/UI build, signature verification and independent review pass; fresh full hosted acceptance and Swift CodeQL remain required. --- App/UITests/PackageInspectionUITests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index 3a51855..50cc89f 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -324,8 +324,10 @@ final class PackageInspectionUITests: XCTestCase { XCTAssertEqual(try XCTUnwrap(field.value as? String), path) field.typeKey(.return, modifierFlags: []) wait(application!.textFields["PathTextField"], predicate: "exists == false", timeout: 5) - let open = panel.buttons["OKButton"] + let openButtons = application!.buttons.matching(identifier: "OKButton") + let open = openButtons.element wait(open, predicate: "exists == true AND enabled == true AND hittable == true", timeout: 10) + XCTAssertEqual(openButtons.count, 1, "The native Open button must resolve uniquely from the application") XCTAssertEqual(try value("package-target-readiness"), "ready", "Choosing a local package must preserve the owned simulator target") attach(application!.debugDescription, name: "package-native-selected-input") open.click() From 81958242213f7912340ae975c268382b1ef2d812 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:40:28 -0800 Subject: [PATCH 13/20] hideouts/package-inspection: resolve the current native Go to Folder sheet --- App/UITests/PackageInspectionUITests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index 50cc89f..6768af3 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -312,8 +312,10 @@ final class PackageInspectionUITests: XCTestCase { let panel = element("open-panel") wait(panel, predicate: "exists == true", timeout: 10) panel.typeKey("g", modifierFlags: [.command, .shift]) - let goTo = panel.descendants(matching: .any)["GoToWindow"] + let goToSheets = application!.sheets.matching(identifier: "GoToWindow") + let goTo = goToSheets.element wait(goTo, predicate: "exists == true", timeout: 5) + XCTAssertEqual(goToSheets.count, 1, "The native Go to Folder sheet must resolve uniquely from the application") let fields = application!.textFields.matching(identifier: "PathTextField") let field = fields.element wait(field, predicate: "exists == true AND hittable == true", timeout: 5) From 7b1ff0d500620e670f7f4aca6b6a8aed9ab68e19 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:49:01 -0800 Subject: [PATCH 14/20] hideouts/package-inspection: trace hosted simulator preparation phases --- scripts/PackageInspectionFixtures.swift | 50 ++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/scripts/PackageInspectionFixtures.swift b/scripts/PackageInspectionFixtures.swift index f50f58e..b3a93ee 100644 --- a/scripts/PackageInspectionFixtures.swift +++ b/scripts/PackageInspectionFixtures.swift @@ -42,6 +42,15 @@ struct PackageInspectionFixtures { let preparedDirectories: [String] } + enum PreparationPhase: String { + case templateBeforeAllocation = "template-before-allocation" + case ownedVerificationAfterBoot = "owned-verification-after-boot" + } + + enum PreparationEvent: String { + case begin, complete, failed + } + static let validName = "ValidSimulator.app" static let validBName = "ValidSimulatorB.app" static let invalidName = "Malformed.ipa" @@ -78,7 +87,9 @@ struct PackageInspectionFixtures { .filter { $0.hasPrefix("idt-prepared-app.") }.sorted() var manifest = Manifest(owner: owner, simulator: nil, sources: nil, inventory: nil, existingPreparations: existingPreparations) try save(manifest, directory: directory) - let template = try await simulatorTemplate(runner: runner) + let template = try await tracePreparationPhase(.templateBeforeAllocation) { + try await simulatorTemplate(runner: runner) + } let result = try await command(["simctl", "create", "IDT Package \(owner)", template.type, template.runtime], timeout: 120, runner: runner) guard let identifier = UUID(uuidString: result.standardOutputText.trimmingCharacters(in: .whitespacesAndNewlines))?.uuidString else { throw failure("simctl create did not return a valid fixture UUID; cleanup must reconcile its exact owner name before retrying.", cause: nil) @@ -86,7 +97,9 @@ struct PackageInspectionFixtures { manifest = Manifest(owner: owner, simulator: identifier, sources: nil, inventory: nil, existingPreparations: existingPreparations) try await register(manifest, directory: directory, runner: runner) _ = try await command(["simctl", "bootstatus", identifier, "-b"], timeout: 600, runner: runner) - try await requireOwnedSimulator(manifest, runner: runner) + try await tracePreparationPhase(.ownedVerificationAfterBoot) { + try await requireOwnedSimulator(manifest, runner: runner) + } let app = try await fixture(directory: directory, name: validName, bundle: fixtureBundle, displayName: "Package Fixture", runner: runner) let appB = try await fixture(directory: directory, name: validBName, bundle: fixtureBBundle, displayName: "Package Fixture B", runner: runner) let malformed = directory.appendingPathComponent(invalidName) @@ -352,6 +365,39 @@ struct PackageInspectionFixtures { } } + /// Records only static preparation stages and monotonic duration. Cleanup never depends + /// on these writes, and a reporting failure cannot replace an existing command failure. + static func tracePreparationPhase(_ phase: PreparationPhase, operation: () async throws -> Value) async throws -> Value { + let clock = ContinuousClock() + let started = clock.now + try writePreparationEvent(phase, event: .begin, elapsed: .zero) + let value: Value + do { + value = try await operation() + } catch { + let primaryError = error + do { + try writePreparationEvent(phase, event: .failed, elapsed: started.duration(to: clock.now)) + } catch let reportingError { + throw ToolkitError.wrap(primaryError, message: "Hosted fixture preparation failed.", recovery: "Inspect the preparation phase and retained ownership registry.") + .appendingDetail("Writing the failed preparation phase also failed: \(bounded(reportingError))") + } + throw primaryError + } + try writePreparationEvent(phase, event: .complete, elapsed: started.duration(to: clock.now)) + return value + } + + static func writePreparationEvent(_ phase: PreparationPhase, event: PreparationEvent, elapsed: Duration) throws { + let duration = elapsed.components + let record = "[package fixtures] preparation phase=\(phase.rawValue) event=\(event.rawValue) elapsed_seconds=\(duration.seconds) elapsed_attoseconds=\(duration.attoseconds)\n" + do { + try FileHandle.standardError.write(contentsOf: Data(record.utf8)) + } catch { + throw ToolkitError.fileSystem("Could not write hosted preparation phase diagnostics to standard error.", path: nil, underlying: error) + } + } + static func command(_ arguments: [String], timeout: TimeInterval, runner: ProcessCommandRunner) async throws -> CommandResult { let request = CommandRequest(executable: try AppleTool.xcrun.locate(), arguments: arguments, timeout: timeout, displayName: "package fixture tool") let result = try await runner.run(request) From c84dcb631fafe63f54867b76b1dcea5522d95e3f Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:37:34 -0800 Subject: [PATCH 15/20] hideouts/package-inspection: observe post-boot command delivery --- scripts/PackageInspectionFixtures.swift | 90 +++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/scripts/PackageInspectionFixtures.swift b/scripts/PackageInspectionFixtures.swift index b3a93ee..8dc190e 100644 --- a/scripts/PackageInspectionFixtures.swift +++ b/scripts/PackageInspectionFixtures.swift @@ -51,6 +51,70 @@ struct PackageInspectionFixtures { case begin, complete, failed } + enum CommandObservationOutcome: String { + case returned, threw + } + + struct OutputDelivery { + let bytes: Int + let first: Duration? + let last: Duration? + + static let empty = OutputDelivery(bytes: 0, first: nil, last: nil) + + func adding(_ data: Data, at elapsed: Duration) -> OutputDelivery { + guard !data.isEmpty else { return self } + return OutputDelivery(bytes: bytes + data.count, first: first ?? elapsed, last: elapsed) + } + } + + /// Observes only the prepared simulator's post-boot query. Delivery times describe when + /// this consumer received nonempty bytes, not JSON validity or the child's exit time. + struct PostBootObservedRunner: CommandRunning { + let base: ProcessCommandRunner + + func run(_ request: CommandRequest) async throws -> CommandResult { + let clock = ContinuousClock() + let started = clock.now + var standardOutput = OutputDelivery.empty + var standardError = OutputDelivery.empty + var final: CommandResult? + let result: CommandResult + do { + for try await event in base.stream(request) { + switch event { + case .standardOutput(let data): + standardOutput = standardOutput.adding(data, at: started.duration(to: clock.now)) + case .standardError(let data): + standardError = standardError.adding(data, at: started.duration(to: clock.now)) + case .finished(let completed): + final = completed + } + } + if Task.isCancelled { throw ToolkitError.cancelled(request.displayName) } + guard let final else { + throw ToolkitError(.internalInconsistency, message: "\(request.displayName) ended without a result.", recovery: nil, technicalDetail: nil) + } + result = final + } catch { + let primaryError = error + do { + try writePostBootObservation(outcome: .threw, standardOutput: standardOutput, standardError: standardError, finishedSeen: final != nil, elapsed: started.duration(to: clock.now)) + } catch let reportingError { + throw ToolkitError.wrap(primaryError, message: "The post-boot simulator query failed.", recovery: "Inspect the delivery summary and original command failure.") + .appendingDetail("Writing the post-boot delivery summary also failed: \(bounded(reportingError))") + } + throw primaryError + } + try writePostBootObservation(outcome: .returned, standardOutput: standardOutput, standardError: standardError, finishedSeen: final != nil, elapsed: started.duration(to: clock.now)) + return result + } + + func stream(_ request: CommandRequest) -> AsyncThrowingStream { + base.stream(request) + } + } + static let validName = "ValidSimulator.app" static let validBName = "ValidSimulatorB.app" static let invalidName = "Malformed.ipa" @@ -98,7 +162,7 @@ struct PackageInspectionFixtures { try await register(manifest, directory: directory, runner: runner) _ = try await command(["simctl", "bootstatus", identifier, "-b"], timeout: 600, runner: runner) try await tracePreparationPhase(.ownedVerificationAfterBoot) { - try await requireOwnedSimulator(manifest, runner: runner) + try await requireOwnedSimulator(manifest, runner: PostBootObservedRunner(base: runner)) } let app = try await fixture(directory: directory, name: validName, bundle: fixtureBundle, displayName: "Package Fixture", runner: runner) let appB = try await fixture(directory: directory, name: validBName, bundle: fixtureBBundle, displayName: "Package Fixture B", runner: runner) @@ -279,7 +343,7 @@ struct PackageInspectionFixtures { return template } - static func simulators(runner: ProcessCommandRunner) async throws -> [Simulator] { + static func simulators(runner: some CommandRunning) async throws -> [Simulator] { let json = try JSONValue.parse(try await command(["simctl", "list", "devices", "--json"], timeout: 60, runner: runner).standardOutput) guard let groups = json["devices"]?.object else { throw failure("simctl returned no device groups.", cause: nil) } return try groups.keys.sorted().flatMap { runtime -> [Simulator] in @@ -292,7 +356,7 @@ struct PackageInspectionFixtures { } } - static func requireOwnedSimulator(_ manifest: Manifest, runner: ProcessCommandRunner) async throws { + static func requireOwnedSimulator(_ manifest: Manifest, runner: some CommandRunning) async throws { guard let identifier = manifest.simulator, try await simulators(runner: runner).contains(where: { $0.identifier == identifier && $0.name == "IDT Package \(manifest.owner)" && $0.available && $0.state == "Booted" @@ -398,7 +462,25 @@ struct PackageInspectionFixtures { } } - static func command(_ arguments: [String], timeout: TimeInterval, runner: ProcessCommandRunner) async throws -> CommandResult { + /// First/last fields are consumer-relative seconds:attoseconds, or `none` when no bytes + /// were delivered. A returned result may be nonzero; normal command validation still runs. + static func writePostBootObservation(outcome: CommandObservationOutcome, standardOutput: OutputDelivery, standardError: OutputDelivery, finishedSeen: Bool, elapsed: Duration) throws { + let duration = elapsed.components + let record = "[package fixtures] postboot-list-stream outcome=\(outcome.rawValue) stdout_bytes=\(standardOutput.bytes) stdout_first=\(relativeDeliveryTime(standardOutput.first)) stdout_last=\(relativeDeliveryTime(standardOutput.last)) stderr_bytes=\(standardError.bytes) stderr_first=\(relativeDeliveryTime(standardError.first)) stderr_last=\(relativeDeliveryTime(standardError.last)) finished_seen=\(finishedSeen) elapsed_seconds=\(duration.seconds) elapsed_attoseconds=\(duration.attoseconds)\n" + do { + try FileHandle.standardError.write(contentsOf: Data(record.utf8)) + } catch { + throw ToolkitError.fileSystem("Could not write post-boot command delivery diagnostics to standard error.", path: nil, underlying: error) + } + } + + static func relativeDeliveryTime(_ duration: Duration?) -> String { + guard let duration else { return "none" } + let components = duration.components + return "\(components.seconds):\(components.attoseconds)" + } + + static func command(_ arguments: [String], timeout: TimeInterval, runner: some CommandRunning) async throws -> CommandResult { let request = CommandRequest(executable: try AppleTool.xcrun.locate(), arguments: arguments, timeout: timeout, displayName: "package fixture tool") let result = try await runner.run(request) guard result.succeeded else { throw failure("A package fixture tool failed.", cause: nil).appendingDetail(String(result.technicalSummary.prefix(1536))) } From 35cf1de74e6c122af430f5e2f43dc78c3718ae80 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:01:28 -0800 Subject: [PATCH 16/20] hideouts/package-inspection: bound long target toolbar labels --- App/iOSDeveloperToolkit/Views/ContentView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/App/iOSDeveloperToolkit/Views/ContentView.swift b/App/iOSDeveloperToolkit/Views/ContentView.swift index 01c7410..5567763 100644 --- a/App/iOSDeveloperToolkit/Views/ContentView.swift +++ b/App/iOSDeveloperToolkit/Views/ContentView.swift @@ -303,12 +303,15 @@ struct TargetPicker: View { Text(model.selectedDevice?.name ?? "Choose a Device") .font(.headline) .lineLimit(1) + .truncationMode(.middle) if let device = model.selectedDevice { Text(device.kind.label) .font(.caption2) .foregroundStyle(device.kind == .simulator ? Color.purple : (device.kind == .demo ? Color.orange : Color.blue)) } } + // Long target names must leave room for navigation at the minimum window width. + .frame(maxWidth: 220, alignment: .leading) } } .menuIndicator(.visible) From 1076a5ec5f806662e369cba5da28d6cd1b696700 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:23:30 -0800 Subject: [PATCH 17/20] hideouts/package-inspection: verify cancelling busy Quit preserves reader --- App/UITests/PackageInspectionUITests.swift | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index 6768af3..c22720a 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -58,8 +58,62 @@ final class PackageInspectionUITests: XCTestCase { application = app launchOwnedApplication(app, fixtures: fixtures) + // Cancel normal Quit while a genuine unfinished reader owns its native handle and copy. + let firstReaderArm = element("package-reader-arm") + reveal(firstReaderArm) + XCTAssertTrue(firstReaderArm.isEnabled) + firstReaderArm.click() + wait(element("package-reader-phase"), predicate: "value == 'armed'", timeout: 5) try choosePackage(fixtures.valid) + wait(element("package-reader-phase"), predicate: "value == 'native-handle-held-before-validation'", timeout: 120) + let firstReaderDirectory = try value("package-reader-directory") + XCTAssertEqual(try value("package-reader-original-url"), fixtures.valid) + XCTAssertEqual(try value("package-reader-package-url"), URL(fileURLWithPath: firstReaderDirectory).appendingPathComponent("ValidSimulator.app").path) + try requirePrivateDirectory(firstReaderDirectory) + try requirePackageCopy(directory: firstReaderDirectory, original: fixtures.valid) + XCTAssertEqual(try value("package-reader-cancelled"), "false") + XCTAssertEqual(try value("package-reader-signature-status"), "pending") + XCTAssertEqual(try value("package-reader-choice-returned"), "false") + XCTAssertEqual(try value("package-is-terminating"), "false") + XCTAssertEqual(try value("package-prepared-id"), "none") + wait(element("package-active-operations"), predicate: "value == '1'", timeout: 10) + wait(element("install-package"), predicate: "exists == true AND enabled == false", timeout: 10) + attachState("package-quit-cancel-reader-held") + + app.typeKey("q", modifierFlags: [.command]) + let quitCancel = element("quit-operations-cancel") + wait(quitCancel, predicate: "exists == true AND hittable == true", timeout: 10) + attach(app.debugDescription, name: "package-quit-cancel-alert") + quitCancel.click() + wait(quitCancel, predicate: "exists == false", timeout: 10) + XCTAssertEqual(app.state, .runningForeground) + XCTAssertEqual(try value("package-is-terminating"), "false") + XCTAssertEqual(try value("package-reader-phase"), "native-handle-held-before-validation") + XCTAssertEqual(try value("package-reader-cancelled"), "false") + XCTAssertEqual(try value("package-reader-signature-status"), "pending") + XCTAssertEqual(try value("package-reader-choice-returned"), "false") + XCTAssertEqual(try value("package-active-operations"), "1") + XCTAssertEqual(try value("package-reader-directory"), firstReaderDirectory) + XCTAssertEqual(try value("package-prepared-id"), "none") + XCTAssertFalse(element("error-message").exists) + XCTAssertFalse(element("package-inspection-failed").exists) + wait(element("install-package"), predicate: "exists == true AND enabled == false", timeout: 10) + try requirePrivateDirectory(firstReaderDirectory) + try requirePackageCopy(directory: firstReaderDirectory, original: fixtures.valid) + attachState("package-quit-cancel-reader-owned") + + let firstReaderRelease = element("package-reader-release") + reveal(firstReaderRelease) + XCTAssertTrue(firstReaderRelease.isEnabled) + firstReaderRelease.click() + wait(element("package-reader-phase"), predicate: "value == 'validation-returned'", timeout: 120) + wait(element("package-reader-choice-returned"), predicate: "value == 'true'", timeout: 10) + wait(element("package-active-operations"), predicate: "value == '0'", timeout: 10) + XCTAssertEqual(try value("package-is-terminating"), "false") + XCTAssertEqual(try value("package-reader-cancelled"), "false") + XCTAssertEqual(try value("package-reader-signature-status"), "valid") let first = try requirePreparation(original: fixtures.valid, activeOperations: 0) + XCTAssertEqual(first.directory, firstReaderDirectory) XCTAssertEqual(try value("package-selection"), "ValidSimulator.app") XCTAssertFalse(element("package-inspection-failed").exists) attachState("package-first-valid") From 728c70bff7b5689fa8f586a8afc82694165beedc Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:03:33 -0800 Subject: [PATCH 18/20] hideouts/package-inspection: cover held-reader failure teardown --- .../AppPackagePreparationTests.swift | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift index f39d014..e2c2a56 100644 --- a/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift +++ b/Tests/ToolkitFeaturesTests/AppPackagePreparationTests.swift @@ -117,6 +117,97 @@ struct AppPackagePreparationTests { } } } + + private enum ReaderObservationFailure: Error { + case interrupted + } + + @Test(.timeLimit(.minutes(1)), .enabled(if: FileManager.default.isExecutableFile(atPath: "/usr/bin/codesign"))) + func failedCallerReleasesItsHeldNativeReaderBeforeRethrowing() async throws { + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "prepared-reader-failure") + defer { removeTestItem(root) } + let app = try makeApp(root, name: "FailedReaderDemo", platform: "iPhoneSimulator") + try await sign(app, in: root) + let filenames: [String] = ["Info.plist", "FailedReaderDemo", "resource.txt"] + let originals: [Data] = try filenames.map { try Data(contentsOf: app.appendingPathComponent($0)) } + let (events, continuation) = AsyncStream.makeStream() + let reader = PackageInspectionReaderAcceptance(observer: { continuation.yield($0) }) + let observedWorkerCancellation = LockedValue(false) + var heldDirectory: URL? + defer { + if let directory = heldDirectory { + var metadata = stat() + let result = lstat(directory.path, &metadata) + let code = errno + if result == 0 { removeTestItem(directory) } + else { #expect(code == ENOENT) } + } + } + + var observedCallerFailure = false + do { + try await withThrowingTaskGroup(of: PreparedAppPackage.self) { group in + group.addTask { + defer { continuation.finish() } + do { + let unexpected = try await PreparedAppPackage.prepareAsyncForReaderAcceptance(app, reader: reader) + Issue.record("Failure teardown returned an installable preparation.") + try unexpected.remove() + return unexpected + } catch let error as CancellationError { + observedWorkerCancellation.withLock { $0 = true } + throw error + } + } + do { + let held = try await withTimeout(10, operation: "observe a native reader before caller failure") { + for await snapshot in events { + if snapshot.phase == .nativeHandleHeldBeforeValidation { return snapshot } + } + throw ToolkitError(.internalInconsistency, message: "The native reader ended before the caller failure could be exercised.", recovery: nil, technicalDetail: nil) + } + let directory = try #require(held.directory) + let copy = try #require(held.packageURL) + heldDirectory = directory + #expect(held.originalURL == app) + #expect(!held.cancellationRequested) + #expect(held.signatureStatus == nil) + var metadata = stat() + #expect(lstat(directory.path, &metadata) == 0) + #expect(metadata.st_mode & S_IFMT == S_IFDIR) + #expect(metadata.st_uid == getuid()) + #expect(metadata.st_mode & 0o777 == 0o700) + for (filename, original) in zip(filenames, originals) { + #expect(try Data(contentsOf: copy.appendingPathComponent(filename)) == original) + } + // Only the caller failure is controlled; the worker and validation are real. + throw ReaderObservationFailure.interrupted + } catch { + group.cancelAll() + reader.finishAfterFailure() + throw error + } + } + } catch ReaderObservationFailure.interrupted { + observedCallerFailure = true + } + + #expect(observedCallerFailure) + #expect(observedWorkerCancellation.current) + #expect(reader.snapshot.phase == .validationReturned) + #expect(reader.snapshot.signatureStatus == .valid) + #expect(reader.snapshot.cancellationRequested) + let directory = try #require(heldDirectory) + #expect(reader.snapshot.directory == directory) + var metadata = stat() + let removal = lstat(directory.path, &metadata) + let removalError = errno + #expect(removal == -1) + #expect(removalError == ENOENT) + for (filename, original) in zip(filenames, originals) { + #expect(try Data(contentsOf: app.appendingPathComponent(filename)) == original) + } + } #endif private enum ChangedLocation: String, CaseIterable { From e37d468848f84ad0c97422c9c6fd79e0f95231f6 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:08:21 -0800 Subject: [PATCH 19/20] hideouts/package-inspection: observe owned postboot query processes Collect bounded direct-child lifecycle metadata and fixed stack-category counts around the unchanged post-boot query. Keep raw reports private and remove them after the owned sampler exits, including cancellation. Strict helper compilation, full parallel package tests, native test build and signature checks passed locally. Actual process probes cover fast completion, capture, timeout and query exit during sampling. Hosted native acceptance remains gated on successful fixture preparation. --- .github/workflows/ci.yml | 3 +- scripts/PackageInspectionFixtures.swift | 19 +- scripts/PackageProcessObservation.swift | 321 ++++++++++++++++++++++++ 3 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 scripts/PackageProcessObservation.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 749fd7a..fd54086 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,8 @@ jobs: xcrun swiftc -swift-version 6 -warnings-as-errors -parse-as-library \ -I "$RUNNER_TEMP/dd/Build/Products/Debug" \ "$RUNNER_TEMP/dd/Build/Products/Debug/ToolkitCore.o" \ - scripts/PackageInspectionFixtures.swift -o "$RUNNER_TEMP/package-fixtures" + scripts/PackageInspectionFixtures.swift scripts/PackageProcessObservation.swift \ + -o "$RUNNER_TEMP/package-fixtures" - name: Prepare one owned package simulator and immutable package inputs id: prepare_package run: | diff --git a/scripts/PackageInspectionFixtures.swift b/scripts/PackageInspectionFixtures.swift index 8dc190e..a749bc4 100644 --- a/scripts/PackageInspectionFixtures.swift +++ b/scripts/PackageInspectionFixtures.swift @@ -70,10 +70,15 @@ struct PackageInspectionFixtures { /// Observes only the prepared simulator's post-boot query. Delivery times describe when /// this consumer received nonempty bytes, not JSON validity or the child's exit time. + /// Owned-process sampling can perturb scheduling. Consumer timing excludes observer + /// teardown; the request's own deadline and process behavior remain unchanged. struct PostBootObservedRunner: CommandRunning { let base: ProcessCommandRunner func run(_ request: CommandRequest) async throws -> CommandResult { + let observation = Task.detached(priority: .utility) { + await PackageProcessObservation(runner: base).collect() + } let clock = ContinuousClock() let started = clock.now var standardOutput = OutputDelivery.empty @@ -98,15 +103,23 @@ struct PackageInspectionFixtures { result = final } catch { let primaryError = error + let consumerElapsed = started.duration(to: clock.now) + observation.cancel() + let processReport = await observation.value do { - try writePostBootObservation(outcome: .threw, standardOutput: standardOutput, standardError: standardError, finishedSeen: final != nil, elapsed: started.duration(to: clock.now)) + try writePostBootObservation(outcome: .threw, standardOutput: standardOutput, standardError: standardError, finishedSeen: final != nil, elapsed: consumerElapsed) + try PackageProcessObservation.write(processReport) } catch let reportingError { throw ToolkitError.wrap(primaryError, message: "The post-boot simulator query failed.", recovery: "Inspect the delivery summary and original command failure.") - .appendingDetail("Writing the post-boot delivery summary also failed: \(bounded(reportingError))") + .appendingDetail("Writing the post-boot observations also failed: \(bounded(reportingError))") } throw primaryError } - try writePostBootObservation(outcome: .returned, standardOutput: standardOutput, standardError: standardError, finishedSeen: final != nil, elapsed: started.duration(to: clock.now)) + let consumerElapsed = started.duration(to: clock.now) + observation.cancel() + let processReport = await observation.value + try writePostBootObservation(outcome: .returned, standardOutput: standardOutput, standardError: standardError, finishedSeen: final != nil, elapsed: consumerElapsed) + try PackageProcessObservation.write(processReport) return result } diff --git a/scripts/PackageProcessObservation.swift b/scripts/PackageProcessObservation.swift new file mode 100644 index 0000000..3e9a99b --- /dev/null +++ b/scripts/PackageProcessObservation.swift @@ -0,0 +1,321 @@ +import Darwin +import Foundation +import ToolkitCore + +/// Observes only this helper and its direct xcrun/simctl child. Sampling perturbs execution; +/// PID identity checks bracket attachment but cannot make the external sample attach atomic. +struct PackageProcessObservation: Sendable { + let runner: ProcessCommandRunner + + enum Target: String, Encodable, Sendable { case helper, xcrun, simctl } + enum Stage: String, Encodable, Sendable { case identity, children, sample, read, parse, cleanup } + enum Code: String, Encodable, Sendable { + case unavailable, invalidMetadata, capacityReached, ambiguous, changedIdentity + case commandFailed, unreadable, oversized, missingCallGraph, cleanupFailed + } + enum SampleStatus: String, Encodable, Sendable { case captured, cancelled, unavailable } + + struct Failure: Encodable, Sendable { + let stage: Stage + let code: Code + let systemCode: Int32? + let toolkitKind: String? + } + struct ChildState: Encodable, Sendable { + let pid: Int32 + let parent: Int32 + let uid: UInt32 + let birthSeconds: UInt64 + let birthMicroseconds: UInt64 + let state: UInt32 + let target: Target + } + struct Snapshot: Encodable, Sendable { + let elapsedMilliseconds: Int64 + let helperState: UInt32 + let matchingChildren: Int + let child: ChildState? + } + struct Counts: Encodable, Sendable { + var frameLines = 0 + var machMessage = 0 + var xpc = 0 + var coreSimulator = 0 + var read = 0 + var write = 0 + var fileHandle = 0 + var consume = 0 + var finish = 0 + var lock = 0 + var semaphore = 0 + var dispatch = 0 + var processWait = 0 + } + struct Sample: Encodable, Sendable { + let target: Target + let elapsedMilliseconds: Int64 + let status: SampleStatus + let counts: Counts? + } + struct Report: Encodable, Sendable { + let helperPID: Int32 + let elapsedMilliseconds: Int64 + let snapshots: [Snapshot] + let samples: [Sample] + let failures: [Failure] + let omittedFailures: Int + let observerCancelled: Bool + let iterationLimitReached: Bool + } + private struct Identity: Equatable, Sendable { + let pid: Int32 + let parent: Int32 + let uid: UInt32 + let birthSeconds: UInt64 + let birthMicroseconds: UInt64 + let name: String + let path: String + + var queryTarget: Target? { + guard URL(fileURLWithPath: path).lastPathComponent == name else { return nil } + if name == "xcrun" { return .xcrun } + if name == "simctl" { return .simctl } + return nil + } + } + private struct ProcessState { + let identity: Identity + let state: UInt32 + } + private struct Fault: Error { + let failure: Failure + let closeFailure: Failure? + } + + func collect() async -> Report { + let clock = ContinuousClock() + let started = clock.now + let helperPID = getpid() + var snapshots: [Snapshot] = [] + var samples: [Sample] = [] + var failures: [Failure] = [] + var iterations = 0 + var sampled = false + var cancelled = false + for _ in 0..<70 { + if Task.isCancelled { cancelled = true; break } + iterations += 1 + do { + let helper = try Self.identity(helperPID) + guard helper.identity.uid == geteuid() else { throw Self.fault(.identity, .invalidMetadata, nil) } + let children = try Self.children(helperPID).compactMap { pid -> ProcessState? in + let child = try Self.identity(pid) + guard child.identity.parent == helperPID, child.identity.uid == geteuid(), + child.identity.queryTarget != nil else { return nil } + return child + } + let elapsed = Self.milliseconds(started.duration(to: clock.now)) + let child = children.count == 1 ? children[0] : nil + snapshots.append(Snapshot(elapsedMilliseconds: elapsed, helperState: helper.state, + matchingChildren: children.count, child: child.flatMap(Self.childState))) + if children.count > 1 { throw Self.fault(.children, .ambiguous, nil) } + if !sampled, elapsed >= 20_000, let child, let target = child.identity.queryTarget { + sampled = true + let childResult = await sample(child.identity, target: target, started: started, clock: clock) + samples.append(childResult.sample) + failures.append(contentsOf: childResult.failures) + if !Task.isCancelled { + let helperResult = await sample(helper.identity, target: .helper, started: started, clock: clock) + samples.append(helperResult.sample) + failures.append(contentsOf: helperResult.failures) + } + } + } catch let error as Fault { + failures.append(error.failure) + } catch { + failures.append(Failure(stage: .identity, code: .unavailable, systemCode: nil, toolkitKind: nil)) + } + do { try await Task.sleep(nanoseconds: 1_000_000_000) } + catch { cancelled = true; break } + } + return Report(helperPID: helperPID, elapsedMilliseconds: Self.milliseconds(started.duration(to: clock.now)), + snapshots: snapshots, samples: samples, failures: Array(failures.prefix(16)), omittedFailures: max(0, failures.count - 16), + observerCancelled: cancelled || Task.isCancelled, iterationLimitReached: !cancelled && !Task.isCancelled && iterations == 70) + } + + static func write(_ report: Report) throws { + let encoded = try JSONEncoder().encode(report) + guard encoded.count <= 32_768 else { + throw ToolkitError(.internalInconsistency, message: "Post-boot process diagnostics exceeded their output bound.", recovery: "Inspect the bounded observation schema.", technicalDetail: nil) + } + var line = Data("[package fixtures] postboot-process ".utf8) + line.append(encoded) + line.append(10) + try FileHandle.standardError.write(contentsOf: line) + } + + private static func childState(_ process: ProcessState) -> ChildState? { + let id = process.identity + guard let target = id.queryTarget else { return nil } + return ChildState(pid: id.pid, parent: id.parent, uid: id.uid, birthSeconds: id.birthSeconds, + birthMicroseconds: id.birthMicroseconds, state: process.state, target: target) + } + + private static func children(_ parent: Int32) throws -> [Int32] { + var pids = [pid_t](repeating: 0, count: 32) + errno = 0 + let count = pids.withUnsafeMutableBytes { proc_listchildpids(parent, $0.baseAddress, Int32($0.count)) } + let code = errno + guard count >= 0, count < pids.count else { throw fault(.children, .capacityReached, code) } + guard count != 0 || code == 0 else { throw fault(.children, .unavailable, code) } + let selected = Array(pids.prefix(Int(count))) + guard selected.allSatisfy({ $0 > 0 }) else { throw fault(.children, .invalidMetadata, nil) } + return selected + } + + private static func identity(_ pid: Int32) throws -> ProcessState { + var info = proc_bsdinfo() + errno = 0 + let size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size)) + guard size == MemoryLayout.size else { throw fault(.identity, .unavailable, errno) } + let nameBytes = withUnsafeBytes(of: info.pbi_comm) { Array($0.prefix { $0 != 0 }) } + // proc_info.h defines the maximum as 4 * MAXPATHLEN; Swift cannot import that macro. + var pathBytes = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) + errno = 0 + let length = pathBytes.withUnsafeMutableBytes { proc_pidpath(pid, $0.baseAddress, UInt32($0.count)) } + guard length > 0, length < pathBytes.count, let end = pathBytes.firstIndex(of: 0), end > 0, + let path = String(bytes: pathBytes.prefix(end).map { UInt8(bitPattern: $0) }, encoding: .utf8), + let name = String(bytes: nameBytes, encoding: .utf8), !name.isEmpty, + path.hasPrefix("/"), info.pbi_pid == UInt32(pid), info.pbi_ppid <= UInt32(Int32.max), + info.pbi_start_tvsec > 0, info.pbi_start_tvusec < 1_000_000 else { throw fault(.identity, .invalidMetadata, nil) } + return ProcessState(identity: Identity(pid: pid, parent: Int32(info.pbi_ppid), uid: info.pbi_uid, + birthSeconds: info.pbi_start_tvsec, birthMicroseconds: info.pbi_start_tvusec, + name: name, path: path), state: info.pbi_status) + } + + private func sample(_ expected: Identity, target: Target, started: ContinuousClock.Instant, clock: ContinuousClock) async -> (sample: Sample, failures: [Failure]) { + let elapsed = Self.milliseconds(started.duration(to: clock.now)) + var directory: URL? + var counts: Counts? + var status = SampleStatus.unavailable + var failures: [Failure] = [] + do { + guard try Self.identity(expected.pid).identity == expected else { throw Self.fault(.sample, .changedIdentity, nil) } + try Task.checkCancellation() + let root = try SecureFileIO.makeTemporaryDirectory(prefix: "idt-postboot-sample") + directory = root + let file = root.appendingPathComponent("sample.txt") + try SecureFileIO.writeNewFile(Data(), to: file, mode: 0o600) + let request = CommandRequest(executable: URL(fileURLWithPath: "/usr/bin/sample"), + arguments: [String(expected.pid), "1", "10", "-mayDie", "-file", file.path], + environment: CommandEnvironment.minimal(adding: [:]), workingDirectory: root, standardInput: nil, + timeout: 8, outputLimit: 8192, displayName: "postboot process sample", terminationGracePeriod: 1) + let result = try await runSample(request) + try Task.checkCancellation() + guard result.succeeded else { throw Self.fault(.sample, .commandFailed, result.exitCode) } + guard try Self.identity(expected.pid).identity == expected else { throw Self.fault(.sample, .changedIdentity, nil) } + counts = try Self.parse(Self.read(file)) + status = .captured + } catch is CancellationError { + status = .cancelled + } catch let error as Fault { + failures.append(error.failure) + if let close = error.closeFailure { failures.append(close) } + } catch let error as ToolkitError { + if Task.isCancelled { status = .cancelled } + else { failures.append(Failure(stage: .sample, code: .commandFailed, systemCode: nil, toolkitKind: error.kind.rawValue)) } + } catch { + failures.append(Failure(stage: .sample, code: .unavailable, systemCode: nil, toolkitKind: nil)) + } + if let directory { + do { try FileManager.default.removeItem(at: directory) } + catch { failures.append(Failure(stage: .cleanup, code: .cleanupFailed, systemCode: nil, toolkitKind: nil)) } + } + return (Sample(target: target, elapsedMilliseconds: elapsed, status: status, counts: counts), failures) + } + + /// Cancellation signals the sampler while its independent consumer retains ownership + /// through actual exit. The caller can then remove the report without a live writer. + private func runSample(_ request: CommandRequest) async throws -> CommandResult { + let cancellation = CommandCancellation() + let consumer = Task.detached(priority: .utility) { + var final: CommandResult? + for try await event in runner.stream(request, cancellation: cancellation) { + if case .finished(let result) = event { final = result } + } + guard let final else { + throw ToolkitError(.internalInconsistency, message: "The process sampler ended without a result.", recovery: "Inspect the sampler's lifecycle.", technicalDetail: nil) + } + return final + } + return try await withTaskCancellationHandler { + try await consumer.value + } onCancel: { + cancellation.cancel() + } + } + + private static func read(_ file: URL) throws -> Data { + let descriptor = open(file.path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { throw fault(.read, .unreadable, errno) } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + var outcome: Result + do { + var metadata = stat() + guard fstat(descriptor, &metadata) == 0 else { throw fault(.read, .unreadable, errno) } + guard metadata.st_mode & S_IFMT == S_IFREG, metadata.st_nlink == 1, + metadata.st_uid == getuid(), metadata.st_mode & 0o777 == 0o600, + metadata.st_size > 0, metadata.st_size <= 2_097_152 else { throw fault(.read, .invalidMetadata, nil) } + guard let data = try handle.read(upToCount: 2_097_153) else { throw fault(.read, .unreadable, nil) } + guard !data.isEmpty, data.count <= 2_097_152 else { throw fault(.read, .oversized, nil) } + outcome = .success(data) + } catch let error as Fault { outcome = .failure(error) } + catch { outcome = .failure(fault(.read, .unreadable, nil)) } + do { try handle.close() } + catch { + let close = Failure(stage: .read, code: .unreadable, systemCode: nil, toolkitKind: nil) + switch outcome { + case .success: throw Fault(failure: close, closeFailure: nil) + case .failure(let primary): throw Fault(failure: primary.failure, closeFailure: close) + } + } + return try outcome.get() + } + + private static func parse(_ data: Data) throws -> Counts { + guard let text = String(data: data, encoding: .utf8) else { throw fault(.parse, .invalidMetadata, nil) } + var active = false + var ended = false + var counts = Counts() + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed == "Call graph:" { active = true; continue } + if active, trimmed.hasPrefix("Total number in stack") || trimmed.hasPrefix("Binary Images:") || trimmed.hasPrefix("Sort by top of stack") { ended = true; break } + guard active, line.contains("(in ") else { continue } + counts.frameLines += 1 + if line.contains("mach_msg") { counts.machMessage += 1 } + if line.contains("xpc_") || line.contains("XPC") { counts.xpc += 1 } + if line.contains("CoreSimulator") || line.contains("SimService") { counts.coreSimulator += 1 } + if line.contains("__read") || line.contains(" read +") { counts.read += 1 } + if line.contains("__write") || line.contains(" write +") { counts.write += 1 } + if line.contains("FileHandle") { counts.fileHandle += 1 } + if line.contains("consume(") || line.contains("consume_") { counts.consume += 1 } + if line.contains("ProcessExecution.finish") { counts.finish += 1 } + if ["NSLock", "NSRecursiveLock", "pthread_mutex", "pthread_rwlock", "os_unfair_lock", "__ulock_wait", "__psynch_mutexwait", "__psynch_rw"].contains(where: line.contains) { counts.lock += 1 } + if line.contains("semaphore") { counts.semaphore += 1 } + if line.contains("dispatch_") { counts.dispatch += 1 } + if line.contains("wait4") || line.contains("waitpid") { counts.processWait += 1 } + } + guard active, ended, counts.frameLines > 0 else { throw fault(.parse, .missingCallGraph, nil) } + return counts + } + + private static func milliseconds(_ duration: Duration) -> Int64 { + let parts = duration.components + return parts.seconds * 1000 + parts.attoseconds / 1_000_000_000_000_000 + } + + private static func fault(_ stage: Stage, _ code: Code, _ systemCode: Int32?) -> Fault { + Fault(failure: Failure(stage: stage, code: code, systemCode: systemCode, toolkitKind: nil), closeFailure: nil) + } +} From 939b853796b85c35ed16f7b84c3ff967851f8b9b Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:37:37 -0800 Subject: [PATCH 20/20] hideouts/package-inspection: keep target picker visible in compact windows Bound the complete target menu and use icon-only primary actions so a long selected simulator name does not displace all controls at the supported minimum window width. Preserve target data, accessible titles, identifiers and commands. Require the picker to remain present and hittable immediately after real selection on both native launches. Strict local build, all 279 parallel tests, UI-test compilation, signature verification and fixed-diff review passed. Hosted native sizing, cleanup and layouts require fresh candidate validation. --- App/UITests/PackageInspectionUITests.swift | 1 + App/iOSDeveloperToolkit/Views/ContentView.swift | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/App/UITests/PackageInspectionUITests.swift b/App/UITests/PackageInspectionUITests.swift index c22720a..fdcc91f 100644 --- a/App/UITests/PackageInspectionUITests.swift +++ b/App/UITests/PackageInspectionUITests.swift @@ -355,6 +355,7 @@ final class PackageInspectionUITests: XCTestCase { wait(simulator, predicate: "exists == true AND hittable == true", timeout: 10) simulator.click() wait(simulator, predicate: "exists == false", timeout: 10) + wait(picker, predicate: "exists == true AND hittable == true", timeout: 10) XCTAssertFalse(element("install-package").exists) } diff --git a/App/iOSDeveloperToolkit/Views/ContentView.swift b/App/iOSDeveloperToolkit/Views/ContentView.swift index 5567763..fdec42d 100644 --- a/App/iOSDeveloperToolkit/Views/ContentView.swift +++ b/App/iOSDeveloperToolkit/Views/ContentView.swift @@ -232,6 +232,7 @@ struct MainToolbar: ToolbarContent { } label: { Label("Refresh Devices", systemImage: "arrow.clockwise") } + .labelStyle(.iconOnly) .help("Look for devices again (⌘R)") .disabled(model.isRefreshing) .accessibilityIdentifier("retry-scan") @@ -240,6 +241,7 @@ struct MainToolbar: ToolbarContent { } label: { Label("Action Palette", systemImage: "command") } + .labelStyle(.iconOnly) .help("Search every workspace and action (⌘K)") .accessibilityIdentifier("toolbar-action-palette") Menu { @@ -255,6 +257,7 @@ struct MainToolbar: ToolbarContent { } label: { Label("Workspace Controls", systemImage: "ellipsis.circle") } + .labelStyle(.iconOnly) .accessibilityIdentifier("workspace-controls") } } @@ -310,12 +313,12 @@ struct TargetPicker: View { .foregroundStyle(device.kind == .simulator ? Color.purple : (device.kind == .demo ? Color.orange : Color.blue)) } } - // Long target names must leave room for navigation at the minimum window width. - .frame(maxWidth: 220, alignment: .leading) } } .menuIndicator(.visible) - .fixedSize() + // Bound the complete menu so long target names cannot displace minimum-width controls. + .frame(width: 220) + .fixedSize(horizontal: false, vertical: true) .help("Choose the device or simulator that actions affect") .accessibilityIdentifier("target-picker") }