diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index 8790ed5..dbf3ff6 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -241,6 +241,38 @@ } } }, + "Cancel": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "Canceling…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Canceling…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在取消…" + } + } + } + }, "Catalog Unavailable": { "localizations": { "en": { @@ -577,6 +609,22 @@ } } }, + "Downloading…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在下载…" + } + } + } + }, "Every app in the catalog is already installed on this simulator.": { "localizations": { "en": { @@ -593,6 +641,22 @@ } } }, + "Install": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装" + } + } + } + }, "Install Xcode to add iPhone and iPad simulators.": { "localizations": { "en": { @@ -625,6 +689,22 @@ } } }, + "Installing…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Installing…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在安装…" + } + } + } + }, "License": { "localizations": { "en": { @@ -801,6 +881,22 @@ } } }, + "Open": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开" + } + } + } + }, "Open %@": { "localizations": { "en": { @@ -897,6 +993,38 @@ } } }, + "Opening…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opening…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在打开…" + } + } + } + }, + "Preparing App…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing App…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在准备应用…" + } + } + } + }, "Productivity": { "localizations": { "en": { @@ -1313,6 +1441,22 @@ } } }, + "The app must contain only arm64 iOS Simulator binaries.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The app must contain only arm64 iOS Simulator binaries." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用必须仅包含 arm64 iOS 模拟器二进制文件。" + } + } + } + }, "The app repository returned HTTP %lld.": { "localizations": { "en": { @@ -1345,6 +1489,118 @@ } } }, + "The download must contain exactly one app bundle with safe file paths.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The download must contain exactly one app bundle with safe file paths." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载包必须仅包含一个应用包,且文件路径不能越界。" + } + } + } + }, + "The download server returned HTTP %lld.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The download server returned HTTP %lld." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载服务器返回了 HTTP %lld。" + } + } + } + }, + "The download server returned an invalid response.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The download server returned an invalid response." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载服务器返回了无效响应。" + } + } + } + }, + "The downloaded app bundle is invalid.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The downloaded app bundle is invalid." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载的应用包无效。" + } + } + } + }, + "The downloaded app does not match the selected app and version.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The downloaded app does not match the selected app and version." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载的应用与所选应用或版本不符。" + } + } + } + }, + "The downloaded file failed its SHA-256 check. Try downloading it again.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The downloaded file failed its SHA-256 check. Try downloading it again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载的文件未通过 SHA-256 校验,请重新下载。" + } + } + } + }, + "The downloaded file has the wrong size. Try downloading it again.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The downloaded file has the wrong size. Try downloading it again." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载的文件大小不符,请重新下载。" + } + } + } + }, "The simulator returned an invalid app list.": { "localizations": { "en": { @@ -1457,6 +1713,22 @@ } } }, + "Verifying…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verifying…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在校验…" + } + } + } + }, "Version": { "localizations": { "en": { diff --git a/docs/cache.md b/docs/cache.md index 80dec6a..d088150 100644 --- a/docs/cache.md +++ b/docs/cache.md @@ -25,3 +25,9 @@ Disk cleanup runs on the first disk access and after a successful write. Modific Downloads use a dedicated ephemeral `URLSession` with its HTTP cache disabled and a 30-second request timeout. At most four downloads run concurrently. Requests for the same key share one task; canceling one view does not cancel that shared task. Canceled callers discard its result, while the completed download remains available to other views and future cache lookups. Corrupt disk images are removed and fetched again. Failed requests and invalid response bodies are not persisted or retained as successful entries. There is no automatic retry loop; a later view request can retry. While no image is available, the existing placeholder remains visible. Refreshing the catalog does not clear icon caches; only a changed key, eviction, or missing/corrupt data requires another download. + +## Installation downloads + +App archives are streamed to a separate, randomly named directory under `URL.temporaryDirectory` for each installation. The download session has its HTTP cache disabled, a 60-second inactivity timeout, and a one-hour resource timeout. Archives are not retained or shared as a persistent download cache; retrying an installation starts a new download. + +The archive's size and SHA-256 must match the selected catalog version before extraction. The temporary archive and extracted app are removed when the operation succeeds, fails, or finishes canceling. Cancellation during preparation waits for any running archive command to finish before cleanup; once `simctl install` starts, it is allowed to finish. A process exit can leave temporary files for the operating system to reclaim. diff --git a/src/AppCatalog.swift b/src/AppCatalog.swift index 67dd7e4..8fb0f33 100644 --- a/src/AppCatalog.swift +++ b/src/AppCatalog.swift @@ -143,7 +143,7 @@ extension CatalogVersion: Decodable { } } -private struct CatalogVersionNumber: Comparable { +struct CatalogVersionNumber: Comparable { let components: [Int] init?(_ text: String) { diff --git a/src/AppDownload.swift b/src/AppDownload.swift new file mode 100644 index 0000000..ed39b94 --- /dev/null +++ b/src/AppDownload.swift @@ -0,0 +1,120 @@ +import Foundation + +enum AppDownload { + typealias Progress = @Sendable (Int64) -> Void + + static func fetch(from url: URL, to destination: URL, progress: @escaping Progress) async throws { + try Task.checkCancellation() + guard !FileManager.default.fileExists(atPath: destination.path) else { + throw CocoaError(.fileWriteFileExists) + } + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) + request.timeoutInterval = 60 + let transfer = AppDownloadTransfer(destination: destination, progress: progress) + do { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { transfer.start(request, continuation: $0) } + } onCancel: { + transfer.cancel() + } + try Task.checkCancellation() + } catch { + try? FileManager.default.removeItem(at: destination) + throw error + } + } + + enum Failure: LocalizedError { + case invalidResponse + case httpStatus(Int) + + var errorDescription: String? { + switch self { + case .invalidResponse: + String(localized: "The download server returned an invalid response.") + case .httpStatus(let status): + String(localized: "The download server returned HTTP \(status).") + } + } + } +} + +private final class AppDownloadTransfer: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { + private let destination: URL + private let progress: AppDownload.Progress + private let lock = NSLock() + private var task: URLSessionDownloadTask? + private var continuation: CheckedContinuation? + private var result: Result? + private var canceled = false + + init(destination: URL, progress: @escaping AppDownload.Progress) { + self.destination = destination + self.progress = progress + } + + func start(_ request: URLRequest, continuation: CheckedContinuation) { + lock.withLock { + guard !canceled else { + continuation.resume(throwing: CancellationError()) + return + } + self.continuation = continuation + let configuration = URLSessionConfiguration.ephemeral + configuration.urlCache = nil + configuration.timeoutIntervalForRequest = 60 + configuration.timeoutIntervalForResource = 3600 + let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + let task = session.downloadTask(with: request) + self.task = task + task.resume() + } + } + + func cancel() { + lock.withLock { + canceled = true + task?.cancel() + } + } + + func urlSession( + _ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 + ) { + progress(totalBytesWritten) + } + + func urlSession( + _ session: URLSession, downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + let result = Result { + guard let response = downloadTask.response as? HTTPURLResponse else { + throw AppDownload.Failure.invalidResponse + } + guard (200..<300).contains(response.statusCode) else { + throw AppDownload.Failure.httpStatus(response.statusCode) + } + try FileManager.default.moveItem(at: location, to: destination) + } + lock.withLock { self.result = result } + } + + func urlSession( + _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? + ) { + let completion = lock.withLock { + let continuation = continuation + self.continuation = nil + self.task = nil + let result: Result = + if canceled { .failure(CancellationError()) } else if let error { .failure(error) } else { + self.result ?? .failure(AppDownload.Failure.invalidResponse) + } + return (continuation, result) + } + session.finishTasksAndInvalidate() + completion.0?.resume(with: completion.1) + } +} diff --git a/src/AppInstallButton.swift b/src/AppInstallButton.swift new file mode 100644 index 0000000..42f70f4 --- /dev/null +++ b/src/AppInstallButton.swift @@ -0,0 +1,105 @@ +import SwiftUI + +struct AppInstallButton: View { + let app: CatalogApp + let version: CatalogVersion + let device: SimulatorDevice + let store: AppInstallationStore + let isInstalled: Bool? + var allowsIncompatible = false + + private enum Action: CaseIterable { + case install, open, cancel, installing, canceling, opening + + var title: String { + switch self { + case .install: String(localized: "Install") + case .open: String(localized: "Open") + case .cancel: String(localized: "Cancel") + case .installing: AppInstallationPhase.installing.title + case .canceling: AppInstallationPhase.canceling.title + case .opening: AppInstallationPhase.opening.title + } + } + } + + private var phase: AppInstallationPhase? { + store.phase(appID: app.id, deviceID: device.id) + } + + private var action: Action { + switch phase { + case .downloading, .verifying, .extracting: .cancel + case .installing: .installing + case .canceling: .canceling + case .opening: .opening + case nil: isInstalled == true ? .open : .install + } + } + + private var enabled: Bool { + if let phase { return phase.canCancel } + guard device.state == "Booted", let isInstalled else { return false } + return isInstalled || allowsIncompatible || version.isCompatible(with: device.runtime) + } + + var body: some View { + Button { + switch action { + case .install: store.install(app: app, version: version, on: device) + case .open: store.open(app: app, on: device) + case .cancel: store.cancel(appID: app.id, deviceID: device.id) + case .installing, .canceling, .opening: break + } + } label: { + StableSizeView(action, states: Action.allCases) { value in + Text(verbatim: value.title) + } + .lineLimit(1) + } + .disabled(!enabled) + .accessibilityLabel(Text(verbatim: "\(action.title) \(app.name)")) + .accessibilityValue(Text(verbatim: phase?.summary(totalBytes: version.size) ?? "")) + .help(Text(verbatim: version.minimumOSLabel)) + } +} + +struct AppInstallStatusView: View { + let appID: String + let deviceID: String + let version: CatalogVersion + let store: AppInstallationStore + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if let phase = store.phase(appID: appID, deviceID: deviceID) { + if case .downloading(let bytes) = phase { + ProgressView(value: Double(min(bytes, version.size)), total: Double(version.size)) { + Text(verbatim: phase.title) + } + Text( + verbatim: + "\(ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)) / \(ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file))" + ) + .font(.system(size: 11)).monospacedDigit().foregroundStyle(.secondary) + } else { + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text(verbatim: phase.title) + } + } + } + if let error = store.error(appID: appID, deviceID: deviceID) { + Label { + Text(verbatim: error).textSelection(.enabled) + } icon: { + Image(systemName: "exclamationmark.triangle") + } + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("installation-error") + } + } + .font(.system(size: 12)) + } +} diff --git a/src/AppInstallationStore.swift b/src/AppInstallationStore.swift new file mode 100644 index 0000000..30a56a7 --- /dev/null +++ b/src/AppInstallationStore.swift @@ -0,0 +1,90 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class AppInstallationStore { + struct Key: Hashable { + let appID: String + let deviceID: String + } + + private var phases: [Key: AppInstallationPhase] = [:] + private var errors: [Key: String] = [:] + @ObservationIgnored private let installer: AppInstaller + @ObservationIgnored private let didInstall: @MainActor (String, String) -> Void + @ObservationIgnored private var jobs: [Key: Task] = [:] + @ObservationIgnored private var requests: [Key: UUID] = [:] + + init( + installer: AppInstaller = AppInstaller(), + didInstall: @escaping @MainActor (String, String) -> Void = { _, _ in } + ) { + self.installer = installer + self.didInstall = didInstall + } + + func phase(appID: String, deviceID: String) -> AppInstallationPhase? { + phases[Key(appID: appID, deviceID: deviceID)] + } + + func error(appID: String, deviceID: String) -> String? { + errors[Key(appID: appID, deviceID: deviceID)] + } + + func install(app: CatalogApp, version: CatalogVersion, on device: SimulatorDevice) { + let key = Key(appID: app.id, deviceID: device.id) + guard jobs[key] == nil else { return } + let request = UUID() + requests[key] = request + errors[key] = nil + phases[key] = .downloading(0) + jobs[key] = Task { [self] in + defer { finish(key) } + do { + try await installer.install(app: app, version: version, on: device) { [weak self] phase in + guard let self, requests[key] == request, phases[key] != .canceling else { return } + if case .downloading(let bytes) = phase, case .downloading(let previous) = phases[key] { + phases[key] = .downloading(max(bytes, previous)) + } else if case .downloading = phase { + return + } else { + phases[key] = phase + } + } + didInstall(app.bundleID, device.id) + } catch is CancellationError { + } catch { + if !Task.isCancelled { errors[key] = error.localizedDescription } + } + } + } + + func cancel(appID: String, deviceID: String) { + let key = Key(appID: appID, deviceID: deviceID) + guard phases[key]?.canCancel == true else { return } + phases[key] = .canceling + jobs[key]?.cancel() + } + + func open(app: CatalogApp, on device: SimulatorDevice) { + let key = Key(appID: app.id, deviceID: device.id) + guard jobs[key] == nil else { return } + errors[key] = nil + phases[key] = .opening + jobs[key] = Task { + defer { finish(key) } + do { + try await installer.open(app: app, on: device) + } catch { + errors[key] = error.localizedDescription + } + } + } + + private func finish(_ key: Key) { + jobs[key] = nil + requests[key] = nil + phases[key] = nil + } +} diff --git a/src/AppInstaller.swift b/src/AppInstaller.swift new file mode 100644 index 0000000..4930469 --- /dev/null +++ b/src/AppInstaller.swift @@ -0,0 +1,89 @@ +import Foundation + +enum AppInstallationPhase: Equatable, Sendable { + case downloading(Int64) + case verifying + case extracting + case installing + case canceling + case opening + + var canCancel: Bool { + switch self { + case .downloading, .verifying, .extracting: true + case .installing, .canceling, .opening: false + } + } + + var title: String { + switch self { + case .downloading: String(localized: "Downloading…") + case .verifying: String(localized: "Verifying…") + case .extracting: String(localized: "Preparing App…") + case .installing: String(localized: "Installing…") + case .canceling: String(localized: "Canceling…") + case .opening: String(localized: "Opening…") + } + } + + func summary(totalBytes: Int64) -> String { + if case .downloading(let bytes) = self { + let fraction = Double(max(0, min(bytes, totalBytes))) / Double(max(1, totalBytes)) + return fraction.formatted(.percent.precision(.fractionLength(0))) + } + return title + } +} + +struct AppInstaller: Sendable { + typealias Download = @Sendable (URL, URL, @escaping AppDownload.Progress) async throws -> Void + typealias Progress = @MainActor @Sendable (AppInstallationPhase) -> Void + + var download: Download = { try await AppDownload.fetch(from: $0, to: $1, progress: $2) } + var command: Command.Runner = Command.run + var temporaryDirectory = URL.temporaryDirectory + + func install( + app: CatalogApp, version: CatalogVersion, on device: SimulatorDevice, + progress: @escaping Progress + ) async throws { + guard device.state == "Booted" else { throw SimulatorApplicationsError.notRunning } + try Task.checkCancellation() + let directory = temporaryDirectory.appending( + component: "\(AppConfiguration.applicationIdentifier)-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let archive = directory.appending(component: "app.tar.bz2") + let url = AppConfiguration.assetURL( + appID: app.id, version: version.version, filename: "\(app.id).app.tar.bz2") + await progress(.downloading(0)) + try await download(url, archive) { bytes in + Task { @MainActor in progress(.downloading(bytes)) } + } + try Task.checkCancellation() + await progress(.verifying) + let preparation = Task.detached(priority: .userInitiated) { + try AppPackage.verify(archive, version: version) + try Task.checkCancellation() + await progress(.extracting) + let bundle = try AppPackage.extract( + archive, to: directory.appending(component: "contents"), command: command) + try AppPackage.validate(bundle, app: app, version: version) + try Task.checkCancellation() + return bundle + } + let bundle = try await withTaskCancellationHandler { + try await preparation.value + } onCancel: { + preparation.cancel() + } + try Task.checkCancellation() + await progress(.installing) + try Task.checkCancellation() + try await SimulatorApplications.install(bundle, on: device, command: command) + } + + func open(app: CatalogApp, on device: SimulatorDevice) async throws { + try await SimulatorApplications.launch(app.bundleID, on: device, command: command) + } +} diff --git a/src/AppPackage.swift b/src/AppPackage.swift new file mode 100644 index 0000000..a797bfc --- /dev/null +++ b/src/AppPackage.swift @@ -0,0 +1,177 @@ +import CryptoKit +import Foundation +import MachO + +enum AppPackage { + static func verify(_ archive: URL, version: CatalogVersion) throws { + let size = try archive.resourceValues(forKeys: [.fileSizeKey]).fileSize + guard size.map(Int64.init) == version.size else { throw Failure.sizeMismatch } + let file = try FileHandle(forReadingFrom: archive) + defer { try? file.close() } + var hash = SHA256() + while let chunk = try file.read(upToCount: 1024 * 1024), !chunk.isEmpty { + try Task.checkCancellation() + hash.update(data: chunk) + } + let digest = hash.finalize().map { String(format: "%02x", $0) }.joined() + guard digest == version.sha256 else { throw Failure.checksumMismatch } + } + + static func extract( + _ archive: URL, to directory: URL, command: Command.Runner = Command.run + ) throws -> URL { + let listing = try command("/usr/bin/tar", ["-tjf", archive.path]) + let paths = String(decoding: listing, as: UTF8.self).split(separator: "\n") + guard !paths.isEmpty, + paths.allSatisfy({ path in + !path.hasPrefix("/") && !path.split(separator: "/").contains("..") + }) + else { throw Failure.invalidArchive } + try Task.checkCancellation() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + // Keep bsdtar's default protections against dot-dot paths and symlink traversal. + _ = try command( + "/usr/bin/tar", + [ + "-xjf", archive.path, "-C", directory.path, "--no-same-owner", "--no-same-permissions", + ]) + try Task.checkCancellation() + let roots = try FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard roots.count == 1, let app = roots.first, app.pathExtension == "app", + try app.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true, + try app.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink != true + else { throw Failure.invalidArchive } + return app + } + + static func validate(_ bundle: URL, app: CatalogApp, version: CatalogVersion) throws { + let root = bundle.resolvingSymlinksInPath().standardizedFileURL + let info = try bundleInfo(root) + guard info["CFBundleIdentifier"] as? String == app.bundleID, + let actualVersion = info["CFBundleShortVersionString"] as? String, + CatalogVersionNumber(actualVersion) == CatalogVersionNumber(version.version) + else { throw Failure.wrongApp } + var required: Set = [] + var checked: Set = [] + var enumerationError: Error? + guard + let files = FileManager.default.enumerator( + at: root, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], + errorHandler: { _, error in + enumerationError = error + return false + }) + else { throw Failure.invalidBundle } + let mainExecutable = try executable(in: root, info: info) + required.insert(mainExecutable) + try validatePlatform(info) + for case let path as URL in files { + try Task.checkCancellation() + let resolved = path.resolvingSymlinksInPath().standardizedFileURL + guard resolved.pathComponents.starts(with: root.pathComponents), + FileManager.default.fileExists(atPath: resolved.path) + else { throw Failure.invalidBundle } + if ["app", "appex", "framework"].contains(path.pathExtension) { + let nestedInfo = try bundleInfo(path) + required.insert(try executable(in: path, info: nestedInfo)) + if path.pathExtension != "framework" { try validatePlatform(nestedInfo) } + } + guard try path.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true, + !checked.contains(resolved) + else { continue } + let file = try FileHandle(forReadingFrom: path) + let magic = try file.read(upToCount: 4) + try file.close() + guard let magic, magic.count == 4, + [ + MH_MAGIC_64, MH_CIGAM_64, MH_MAGIC, MH_CIGAM, + FAT_MAGIC, FAT_CIGAM, FAT_MAGIC_64, FAT_CIGAM_64, + ].contains(word(magic, at: 0)) + else { continue } + let data = try Data(contentsOf: path, options: .mappedIfSafe) + try validateMachO(data, isMainExecutable: resolved == mainExecutable) + checked.insert(resolved) + } + if let enumerationError { throw enumerationError } + guard required.isSubset(of: checked) else { throw Failure.invalidBundle } + } + + private static func bundleInfo(_ bundle: URL) throws -> [String: Any] { + let candidates = [ + bundle.appending(path: "Info.plist"), bundle.appending(path: "Resources/Info.plist"), + ] + guard let path = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }), + let info = try PropertyListSerialization.propertyList( + from: Data(contentsOf: path), format: nil) as? [String: Any] + else { throw Failure.invalidBundle } + return info + } + + private static func executable(in bundle: URL, info: [String: Any]) throws -> URL { + guard let name = info["CFBundleExecutable"] as? String, !name.isEmpty, + name != ".", name != "..", !name.contains("/"), + FileManager.default.isExecutableFile(atPath: bundle.appending(component: name).path) + else { throw Failure.invalidBundle } + return bundle.appending(component: name).resolvingSymlinksInPath().standardizedFileURL + } + + private static func validatePlatform(_ info: [String: Any]) throws { + guard info["CFBundleSupportedPlatforms"] as? [String] == ["iPhoneSimulator"], + let minimum = info["MinimumOSVersion"] as? String, CatalogVersionNumber(minimum) != nil + else { throw Failure.invalidBundle } + } + + private static func validateMachO(_ data: Data, isMainExecutable: Bool) throws { + guard data.count >= 32, word(data, at: 0) == MH_MAGIC_64, + word(data, at: 4) == UInt32(CPU_TYPE_ARM64), + !isMainExecutable || word(data, at: 12) == MH_EXECUTE + else { throw Failure.unsupportedBinary } + let count = Int(word(data, at: 16)) + let end = 32 + Int(word(data, at: 20)) + guard end <= data.count, count <= (end - 32) / 8 else { throw Failure.unsupportedBinary } + var offset = 32 + var platforms: [UInt32] = [] + for _ in 0..= 8, offset + size <= end else { throw Failure.unsupportedBinary } + if command == LC_BUILD_VERSION { + guard size >= 24 else { throw Failure.unsupportedBinary } + platforms.append(word(data, at: offset + 8)) + } + offset += size + } + guard offset == end, platforms == [UInt32(PLATFORM_IOSSIMULATOR)] else { + throw Failure.unsupportedBinary + } + } + + private static func word(_ data: Data, at offset: Int) -> UInt32 { + data.withUnsafeBytes { + UInt32(littleEndian: $0.loadUnaligned(fromByteOffset: offset, as: UInt32.self)) + } + } + + enum Failure: LocalizedError { + case sizeMismatch, checksumMismatch, invalidArchive, wrongApp, invalidBundle, unsupportedBinary + + var errorDescription: String? { + switch self { + case .sizeMismatch: + String(localized: "The downloaded file has the wrong size. Try downloading it again.") + case .checksumMismatch: + String(localized: "The downloaded file failed its SHA-256 check. Try downloading it again.") + case .invalidArchive: + String(localized: "The download must contain exactly one app bundle with safe file paths.") + case .wrongApp: + String(localized: "The downloaded app does not match the selected app and version.") + case .invalidBundle: + String(localized: "The downloaded app bundle is invalid.") + case .unsupportedBinary: + String(localized: "The app must contain only arm64 iOS Simulator binaries.") + } + } + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 77a2284..3ee9d09 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -14,11 +14,17 @@ add_executable(SimStore MACOSX_BUNDLE ContentView.swift DiscoverHeading.swift AppCatalog.swift + AppDownload.swift + AppPackage.swift + AppInstaller.swift + AppInstallationStore.swift + AppInstallButton.swift CatalogClient.swift CatalogStore.swift CatalogPresentation.swift IconCache.swift CatalogBrowserView.swift + CatalogAppCard.swift CatalogAppDetailView.swift SidebarView.swift NoSimulatorsView.swift diff --git a/src/CatalogAppCard.swift b/src/CatalogAppCard.swift new file mode 100644 index 0000000..3270c68 --- /dev/null +++ b/src/CatalogAppCard.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct CatalogAppCard: View { + let app: CatalogApp + let device: SimulatorDevice + let installedApps: InstalledAppsStore + let installations: AppInstallationStore + @FocusState.Binding var focusedAppID: String? + let showDetails: () -> Void + + private var runtime: String { device.runtime } + + var body: some View { + let version = app.recommendedVersion(for: runtime) + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + CatalogIcon(app: app) + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: app.name) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(2, reservesSpace: true) + Text(verbatim: app.categoryTitle) + .font(.system(size: 10)).foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + Text(verbatim: app.displayDescription) + .font(.system(size: 12)).foregroundStyle(.secondary) + .lineLimit(2, reservesSpace: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + HStack { + HStack(spacing: 4) { + if let phase = installations.phase(appID: app.id, deviceID: device.id) { + Text(verbatim: phase.summary(totalBytes: version.size)).foregroundStyle(.secondary) + .monospacedDigit() + } else if installations.error(appID: app.id, deviceID: device.id) != nil { + Image(systemName: "exclamationmark.triangle").foregroundStyle(.red) + Text(verbatim: version.version).monospacedDigit() + } else if !version.isCompatible(with: runtime) { + Label("iOS \(version.minSimulatorOS)+", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } else { + Text(verbatim: version.version).monospacedDigit() + } + } + .lineLimit(1) + .allowsHitTesting(false) + .accessibilityHidden(true) + Spacer(minLength: 4) + Text(verbatim: ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file)) + .foregroundStyle(.secondary) + .lineLimit(1) + .allowsHitTesting(false) + .accessibilityHidden(true) + AppInstallButton( + app: app, version: version, device: device, store: installations, + isInstalled: installedApps.isInstalled(bundleID: app.bundleID, deviceID: device.id) + ) + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("catalog-install-\(app.id)") + } + .font(.system(size: 11)) + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + Button(action: showDetails) { + RoundedRectangle(cornerRadius: 14).fill(Color(nsColor: .controlBackgroundColor)) + .contentShape(RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(.plain) + .focused($focusedAppID, equals: app.id) + .accessibilityLabel(Text(verbatim: app.name)) + .accessibilityValue( + Text( + verbatim: + "\(app.displayDescription) · \(version.version) · \(version.minimumOSLabel) · \(ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file))" + ) + ) + .accessibilityHint(Text("View app details and versions")) + .accessibilityIdentifier("catalog-app-\(app.id)") + .accessibilitySortPriority(1) + .help( + Text( + verbatim: installations.error(appID: app.id, deviceID: device.id) + ?? app.displayDescription)) + } + .overlay { + RoundedRectangle(cornerRadius: 14).strokeBorder(Color.primary.opacity(0.07)) + } + .contentShape(RoundedRectangle(cornerRadius: 14)) + } +} diff --git a/src/CatalogAppDetailView.swift b/src/CatalogAppDetailView.swift index aed0db5..0d7ec66 100644 --- a/src/CatalogAppDetailView.swift +++ b/src/CatalogAppDetailView.swift @@ -3,16 +3,25 @@ import SwiftUI @MainActor struct CatalogAppDetailView: View { let app: CatalogApp - let runtime: String + let device: SimulatorDevice + let installedApps: InstalledAppsStore + let installations: AppInstallationStore @State private var versionID: String @Environment(\.dismiss) private var dismiss - init(app: CatalogApp, runtime: String) { + init( + app: CatalogApp, device: SimulatorDevice, + installedApps: InstalledAppsStore, installations: AppInstallationStore + ) { self.app = app - self.runtime = runtime - _versionID = State(initialValue: app.recommendedVersion(for: runtime).version) + self.device = device + self.installedApps = installedApps + self.installations = installations + _versionID = State(initialValue: app.recommendedVersion(for: device.runtime).version) } + private var runtime: String { device.runtime } + private var version: CatalogVersion { app.versions.first(where: { $0.version == versionID }) ?? app.recommendedVersion(for: runtime) } @@ -32,6 +41,10 @@ struct CatalogAppDetailView: View { } Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 16) { + GridRow { + Text("Simulator").foregroundStyle(.secondary) + Text(verbatim: "\(device.name) · \(device.runtimeLabel)") + } GridRow { Text("Version").foregroundStyle(.secondary) Picker(selection: $versionID) { @@ -43,6 +56,7 @@ struct CatalogAppDetailView: View { } .labelsHidden() .accessibilityIdentifier("catalog-version") + .disabled(installations.phase(appID: app.id, deviceID: device.id) != nil) .frame(maxWidth: 240) } GridRow { @@ -86,6 +100,9 @@ struct CatalogAppDetailView: View { .fixedSize(horizontal: false, vertical: true) } + AppInstallStatusView( + appID: app.id, deviceID: device.id, version: version, store: installations) + HStack(spacing: 20) { Link(destination: app.repository) { Label("Source Repository", systemImage: "curlybraces") @@ -124,8 +141,16 @@ struct CatalogAppDetailView: View { } Divider() HStack { + Button("Done") { dismiss() }.keyboardShortcut(.cancelAction) Spacer() - Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) + AppInstallButton( + app: app, version: version, device: device, store: installations, + isInstalled: installedApps.isInstalled(bundleID: app.bundleID, deviceID: device.id), + allowsIncompatible: true + ) + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("detail-install") } .padding(20) } diff --git a/src/CatalogBrowserView.swift b/src/CatalogBrowserView.swift index 779497b..9f9af25 100644 --- a/src/CatalogBrowserView.swift +++ b/src/CatalogBrowserView.swift @@ -4,6 +4,7 @@ import SwiftUI struct CatalogBrowserView: View { let store: CatalogStore let installedApps: InstalledAppsStore + let installations: AppInstallationStore let device: SimulatorDevice let isStarting: Bool let deviceManagerName: String? @@ -85,7 +86,8 @@ struct CatalogBrowserView: View { if !category.isEmpty && !values.contains(category) { category = "" } } .sheet(item: $selectedApp) { app in - CatalogAppDetailView(app: app, runtime: device.runtime) + CatalogAppDetailView( + app: app, device: device, installedApps: installedApps, installations: installations) } .popover(isPresented: $showsSimulator) { SimulatorDetailView( @@ -195,25 +197,13 @@ struct CatalogBrowserView: View { let index = row * columns + column if index < visibleApps.count { let app = visibleApps[index] - Button { - selectedApp = app - } label: { - CatalogAppCard(app: app, runtime: device.runtime) - .frame(width: cardWidth) - } - .buttonStyle(.plain) - .focused($focusedAppID, equals: app.id) - .id(app.id) - .accessibilityLabel(Text(verbatim: app.name)) - .accessibilityValue( - Text( - verbatim: - "\(app.displayDescription) · \(app.recommendedVersion(for: device.runtime).version) · \(app.recommendedVersion(for: device.runtime).minimumOSLabel)" - ) + CatalogAppCard( + app: app, device: device, installedApps: installedApps, + installations: installations, focusedAppID: $focusedAppID, + showDetails: { selectedApp = app } ) - .accessibilityHint(Text("View app details and versions")) - .accessibilityIdentifier("catalog-app-\(app.id)") - .help(Text(verbatim: app.displayDescription)) + .frame(width: cardWidth) + .id(app.id) } else { Color.clear.frame(width: cardWidth) .gridCellUnsizedAxes(.vertical) @@ -241,49 +231,3 @@ struct CatalogBrowserView: View { refreshInstalledApps() } } - -private struct CatalogAppCard: View { - let app: CatalogApp - let runtime: String - - var body: some View { - let version = app.recommendedVersion(for: runtime) - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 12) { - CatalogIcon(app: app) - VStack(alignment: .leading, spacing: 4) { - Text(verbatim: app.name) - .font(.system(size: 15, weight: .semibold)) - .lineLimit(2, reservesSpace: true) - Text(verbatim: app.categoryTitle) - .font(.system(size: 10)).foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer(minLength: 0) - } - Text(verbatim: app.displayDescription) - .font(.system(size: 12)).foregroundStyle(.secondary) - .lineLimit(2, reservesSpace: true) - .frame(maxWidth: .infinity, alignment: .leading) - HStack { - Text(verbatim: version.version).monospacedDigit() - Spacer(minLength: 4) - if !version.isCompatible(with: runtime) { - Label("iOS \(version.minSimulatorOS)+", systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) - } else { - Text(verbatim: ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file)) - .foregroundStyle(.secondary) - } - } - .font(.system(size: 11)) - } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 14)) - .overlay { - RoundedRectangle(cornerRadius: 14).strokeBorder(Color.primary.opacity(0.07)) - } - .contentShape(RoundedRectangle(cornerRadius: 14)) - } -} diff --git a/src/ContentView.swift b/src/ContentView.swift index 08edead..4129bec 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -6,6 +6,7 @@ struct ContentView: View { @State private var simulators: SimulatorStore @State private var catalog: CatalogStore @State private var installedApps: InstalledAppsStore + @State private var installations: AppInstallationStore @State private var launchError: String? private static var installedXcodeURL: URL? { @@ -25,13 +26,21 @@ struct ContentView: View { try await SimulatorControl.boot($0) }, catalogClient: CatalogClient = .live, + installer: AppInstaller = AppInstaller(), loadInstalledApps: @escaping @Sendable (SimulatorDevice) async throws -> Set = { try await SimulatorApplications.installedBundleIDs(on: $0) } ) { _simulators = State(initialValue: SimulatorStore(discover: discover, boot: boot)) _catalog = State(initialValue: CatalogStore(client: catalogClient)) - _installedApps = State(initialValue: InstalledAppsStore(load: loadInstalledApps)) + let installedApps = InstalledAppsStore(load: loadInstalledApps) + _installedApps = State(initialValue: installedApps) + _installations = State( + initialValue: AppInstallationStore( + installer: installer, + didInstall: { bundleID, deviceID in + installedApps.recordInstallation(bundleID: bundleID, deviceID: deviceID) + })) } private var needsXcode: Bool { @@ -144,7 +153,8 @@ struct ContentView: View { case .some(.success): if let device = simulators.selectedDevice { CatalogBrowserView( - store: catalog, installedApps: installedApps, device: device, + store: catalog, installedApps: installedApps, installations: installations, + device: device, isStarting: simulators.isStartingSelectedDevice, deviceManagerName: deviceManager?.name, startSimulator: startSimulator, diff --git a/src/InstalledAppsStore.swift b/src/InstalledAppsStore.swift index f2c0456..126dd54 100644 --- a/src/InstalledAppsStore.swift +++ b/src/InstalledAppsStore.swift @@ -15,6 +15,8 @@ final class InstalledAppsStore { private var selectedDevice: SimulatorDevice? @ObservationIgnored private var request: Request? + @ObservationIgnored + private var installedDuringRefresh: Set = [] init( load: @escaping @Sendable (SimulatorDevice) async throws -> Set = { @@ -30,6 +32,17 @@ final class InstalledAppsStore { return catalog.apps.filter { !installed.contains($0.bundleID.lowercased()) } } + func recordInstallation(bundleID: String, deviceID: String) { + guard self.deviceID == deviceID else { return } + installedDuringRefresh.insert(bundleID) + bundleIDs?.insert(bundleID) + } + + func isInstalled(bundleID: String, deviceID: String) -> Bool? { + guard self.deviceID == deviceID, let bundleIDs else { return nil } + return bundleIDs.contains { $0.caseInsensitiveCompare(bundleID) == .orderedSame } + } + func refresh(for device: SimulatorDevice) async { guard !Task.isCancelled else { return } if let pending = request, pending.device == device { @@ -43,6 +56,7 @@ final class InstalledAppsStore { request = pending selectedDevice = device deviceID = device.id + installedDuringRefresh = [] bundleIDs = nil error = nil isLoading = true @@ -56,7 +70,7 @@ final class InstalledAppsStore { do { let installed = try await load(device) try Task.checkCancellation() - if request === pending { bundleIDs = installed } + if request === pending { bundleIDs = installed.union(installedDuringRefresh) } } catch is CancellationError { } catch { if request === pending && !Task.isCancelled { diff --git a/src/SimulatorApplications.swift b/src/SimulatorApplications.swift index 1c42d79..e1022fc 100644 --- a/src/SimulatorApplications.swift +++ b/src/SimulatorApplications.swift @@ -1,6 +1,28 @@ import Foundation enum SimulatorApplications { + static func install( + _ bundle: URL, on device: SimulatorDevice, + command: @escaping Command.Runner = Command.run + ) async throws { + guard device.state == "Booted" else { throw SimulatorApplicationsError.notRunning } + try Task.checkCancellation() + try await Task.detached(priority: .userInitiated) { + _ = try Command.simctl(["install", device.id, bundle.path], using: command) + }.value + } + + static func launch( + _ bundleID: String, on device: SimulatorDevice, + command: @escaping Command.Runner = Command.run + ) async throws { + guard device.state == "Booted" else { throw SimulatorApplicationsError.notRunning } + try Task.checkCancellation() + try await Task.detached(priority: .userInitiated) { + _ = try Command.simctl(["launch", device.id, bundleID], using: command) + }.value + } + static func installedBundleIDs( on device: SimulatorDevice, command: @escaping Command.Runner = Command.run diff --git a/tests/AppDownloadTests.swift b/tests/AppDownloadTests.swift new file mode 100644 index 0000000..6dab793 --- /dev/null +++ b/tests/AppDownloadTests.swift @@ -0,0 +1,135 @@ +import Darwin +import Foundation + +enum AppDownloadTests { + @MainActor + static func run() async throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let payload = Data(repeating: 0x42, count: 4 * 1024 * 1024) + let server = try DownloadTestServer(data: payload) + let serving = Task.detached { server.serve() } + let counter = DownloadCounter() + let destination = directory.appending(component: "download") + try await AppDownload.fetch(from: server.url, to: destination) { counter.record($0) } + await serving.value + try InstallationFixtures.require(counter.bytes > 0, "Real HTTP downloads must report progress.") + try InstallationFixtures.require( + try Data(contentsOf: destination) == payload, "Write the complete download to disk.") + + let failureServer = try DownloadTestServer(data: Data("Unavailable".utf8), status: 503) + let failedServing = Task.detached { failureServer.serve() } + let failed = directory.appending(component: "failed") + do { + try await AppDownload.fetch(from: failureServer.url, to: failed) { _ in } + throw InstallationFixtures.failure("Do not accept HTTP errors as downloads.") + } catch AppDownload.Failure.httpStatus(503) {} + await failedServing.value + try InstallationFixtures.require( + !FileManager.default.fileExists(atPath: failed.path), "Discard failed HTTP responses.") + + let cancelServer = try DownloadTestServer(data: payload) + let cancelServing = Task.detached { cancelServer.serve() } + let canceled = directory.appending(component: "canceled") + let cancelCounter = DownloadCounter() + let download = Task { + try await AppDownload.fetch(from: cancelServer.url, to: canceled) { cancelCounter.record($0) } + } + try await InstallationFixtures.eventually { cancelCounter.bytes > 0 } + download.cancel() + do { + try await download.value + throw InstallationFixtures.failure("A canceled download must throw.") + } catch is CancellationError {} + await cancelServing.value + try InstallationFixtures.require( + !FileManager.default.fileExists(atPath: canceled.path), "Remove canceled downloads.") + do { + try await AppDownload.fetch(from: server.url, to: destination) { _ in } + throw InstallationFixtures.failure("Do not replace an existing destination.") + } catch CocoaError.fileWriteFileExists {} + try InstallationFixtures.require( + try Data(contentsOf: destination) == payload, + "Preserve a pre-existing file when a download cannot start.") + } +} + +private final class DownloadCounter: @unchecked Sendable { + private let lock = NSLock() + private var received: Int64 = 0 + var bytes: Int64 { lock.withLock { received } } + func record(_ bytes: Int64) { lock.withLock { received = max(received, bytes) } } +} + +private final class DownloadTestServer: Sendable { + let url: URL + private let socket: Int32 + private let data: Data + private let status: Int + + init(data: Data, status: Int = 200) throws { + let socket = Darwin.socket(AF_INET, SOCK_STREAM, 0) + guard socket >= 0 else { throw InstallationFixtures.failure("Cannot open test socket.") } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(socket, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, listen(socket, 1) == 0 else { + close(socket) + throw InstallationFixtures.failure("Cannot bind test socket.") + } + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(socket, $0, &length) } + } + guard named == 0 else { + close(socket) + throw InstallationFixtures.failure("Cannot read test port.") + } + self.socket = socket + self.data = data + self.status = status + url = URL(string: "http://127.0.0.1:\(UInt16(bigEndian: address.sin_port))/app.tar.bz2")! + } + + func serve() { + defer { close(socket) } + var descriptor = pollfd(fd: socket, events: Int16(POLLIN), revents: 0) + guard poll(&descriptor, 1, 5000) > 0 else { return } + let client = accept(socket, nil, nil) + guard client >= 0 else { return } + defer { close(client) } + var noSignal: Int32 = 1 + setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) + var timeout = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + var request = [UInt8](repeating: 0, count: 8192) + guard recv(client, &request, request.count, 0) > 0 else { return } + let header = Data( + "HTTP/1.1 \(status) Test\r\nContent-Length: \(data.count)\r\nConnection: close\r\n\r\n".utf8) + guard write(header, to: client) else { return } + for offset in stride(from: 0, to: data.count, by: 32 * 1024) { + guard write(data.subdata(in: offset.. Bool { + bytes.withUnsafeBytes { buffer in + var offset = 0 + while offset < buffer.count { + let count = send(client, buffer.baseAddress!.advanced(by: offset), buffer.count - offset, 0) + if count <= 0 { return false } + offset += count + } + return true + } + } +} diff --git a/tests/AppInstallationTests.swift b/tests/AppInstallationTests.swift new file mode 100644 index 0000000..7508efa --- /dev/null +++ b/tests/AppInstallationTests.swift @@ -0,0 +1,341 @@ +import Foundation + +@main +@MainActor +struct AppInstallationTests { + static func main() async { + do { + try AppPackageTests.run() + try await AppDownloadTests.run() + try await pipeline() + try await failureAndCleanup() + try await perDeviceJobs() + try await cancellationDuringPreparation() + try await installingCannotBeCanceled() + try await stoppedDevice() + print( + "PASS: package verification, safe extraction, Simulator binaries, install/open, per-device jobs, cancellation, failures, and cleanup." + ) + } catch { + FileHandle.standardError.write( + Data("Installation test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + static func pipeline() async throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try archiveData(in: directory) + let work = directory.appending(component: "work") + let commands = InstallationCommands() + let installer = AppInstaller( + download: { url, destination, progress in + try InstallationFixtures.require( + url + == AppConfiguration.assetURL( + appID: "example", version: "1.0.0", filename: "example.app.tar.bz2"), + "Download the selected version's stable release asset.") + try data.write(to: destination) + progress(Int64(data.count)) + }, command: commands.run, temporaryDirectory: work) + let version = InstallationFixtures.version(data: data) + var phases: [AppInstallationPhase] = [] + try await installer.install( + app: InstallationFixtures.app(version: version), version: version, + on: InstallationFixtures.device + ) { + phases.append($0) + } + try InstallationFixtures.require( + phases.contains(.verifying) && phases.contains(.extracting) && phases.contains(.installing), + "Report preparation and installation stages.") + let installed = commands.calls.filter { $0.first == "simctl" } + try InstallationFixtures.require( + installed.count == 1 && installed[0].prefix(3) == ["simctl", "install", "device-A"], + "Install once on the exact target device.") + try InstallationFixtures.require( + try FileManager.default.contentsOfDirectory(atPath: work.path).isEmpty, + "Remove the archive and extracted app after installation.") + try await installer.open(app: InstallationFixtures.app(), on: InstallationFixtures.device) + try InstallationFixtures.require( + commands.calls.last == ["simctl", "launch", "device-A", "org.example.App"], + "Open the installed app on its target device.") + } + + static func failureAndCleanup() async throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try archiveData(in: directory) + let work = directory.appending(component: "work") + for mode in 0..<4 { + let commands = InstallationCommands(failsInstallation: mode == 3) + let installer = AppInstaller( + download: { _, destination, _ in + if mode == 0 { throw InstallationFixtures.failure("Download failed") } + var bytes = data + if mode == 1 { bytes.removeLast() } + if mode == 2 { bytes[bytes.count - 1] ^= 0xFF } + try bytes.write(to: destination) + }, command: commands.run, temporaryDirectory: work) + do { + try await installer.install( + app: InstallationFixtures.app(), version: InstallationFixtures.version(data: data), + on: InstallationFixtures.device + ) { _ in } + throw InstallationFixtures.failure( + "A failed download, verification, or install must fail the pipeline.") + } catch { + if mode < 3 { + try InstallationFixtures.require( + commands.calls.isEmpty, "Unverified data must not be extracted or installed.") + } else { + try InstallationFixtures.require( + error.localizedDescription == "Install failed", "Preserve simctl failure diagnostics.") + } + } + try InstallationFixtures.require( + try FileManager.default.contentsOfDirectory(atPath: work.path).isEmpty, + "Clean temporary files on every failure path.") + } + } + + static func perDeviceJobs() async throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try archiveData(in: directory) + let gate = InstallationDownloadGate(data: data) + let commands = InstallationCommands() + let inventory = InstalledAppsStore(load: { _ in [] }) + await inventory.refresh(for: InstallationFixtures.device) + let store = AppInstallationStore( + installer: AppInstaller( + download: { try await gate.download($0, $1, $2) }, command: commands.run, + temporaryDirectory: directory.appending(component: "work")), + didInstall: { inventory.recordInstallation(bundleID: $0, deviceID: $1) }) + let version = InstallationFixtures.version(data: data) + let app = InstallationFixtures.app(version: version) + let a = InstallationFixtures.device + let b = SimulatorDevice(id: "device-B", name: a.name, runtime: a.runtime, state: a.state) + store.install(app: app, version: version, on: a) + try await gate.waitForCount(1) + store.install(app: app, version: version, on: a) + store.install(app: app, version: version, on: b) + try await gate.waitForCount(2) + try InstallationFixtures.require( + await gate.count == 2, "Do not duplicate a job for the same app and device.") + await gate.report(0, bytes: 100) + await gate.report(0, bytes: 50) + try await InstallationFixtures.eventually { + store.phase(appID: app.id, deviceID: a.id) == .downloading(100) + } + await gate.finish(1) + try await InstallationFixtures.eventually { store.phase(appID: app.id, deviceID: b.id) == nil } + try InstallationFixtures.require( + inventory.bundleIDs == [], + "Installing on another simulator must not alter the selected inventory.") + store.cancel(appID: app.id, deviceID: a.id) + try InstallationFixtures.require( + store.phase(appID: app.id, deviceID: a.id) == .canceling, + "Keep a canceled operation visible until its cleanup finishes.") + await gate.finish(0) + try await InstallationFixtures.eventually { store.phase(appID: app.id, deviceID: a.id) == nil } + try InstallationFixtures.require( + store.error(appID: app.id, deviceID: a.id) == nil, + "Cancellation is not an installation error.") + store.install(app: app, version: version, on: a) + try await gate.waitForCount(3) + await gate.report(0, bytes: 999) + try await Task.sleep(for: .milliseconds(10)) + try InstallationFixtures.require( + store.phase(appID: app.id, deviceID: a.id) == .downloading(0), + "Ignore late progress from an earlier attempt.") + await gate.finish(2) + try await InstallationFixtures.eventually { store.phase(appID: app.id, deviceID: a.id) == nil } + try InstallationFixtures.require( + inventory.isInstalled(bundleID: app.bundleID, deviceID: a.id) == true, + "A successful install updates the selected inventory immediately.") + let installedDevices = commands.calls.filter { $0.prefix(2) == ["simctl", "install"] }.map { + $0[2] + } + try InstallationFixtures.require( + installedDevices == [b.id, a.id], + "Cancellation and simulator changes must not retarget an operation.") + store.open(app: app, on: b) + try await InstallationFixtures.eventually { store.phase(appID: app.id, deviceID: b.id) == nil } + try InstallationFixtures.require( + commands.calls.last == ["simctl", "launch", b.id, app.bundleID], + "Launch on the requested device.") + } + + static func cancellationDuringPreparation() async throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try archiveData(in: directory) + let commands = InstallationCommands(blocksExtraction: true) + let work = directory.appending(component: "work") + let store = AppInstallationStore( + installer: AppInstaller( + download: { _, destination, _ in + try data.write(to: destination) + }, command: commands.run, temporaryDirectory: work)) + let app = InstallationFixtures.app() + let device = InstallationFixtures.device + store.install(app: app, version: InstallationFixtures.version(data: data), on: device) + try InstallationFixtures.require( + await Task.detached { commands.waitForStart() }.value, + "Start preparation before canceling.") + store.cancel(appID: app.id, deviceID: device.id) + commands.finish.signal() + try await InstallationFixtures.eventually { + store.phase(appID: app.id, deviceID: device.id) == nil + } + try InstallationFixtures.require( + !commands.calls.contains { $0.first == "simctl" }, + "A canceled preparation must not install anything.") + try InstallationFixtures.require( + try FileManager.default.contentsOfDirectory(atPath: work.path).isEmpty, + "Remove canceled preparation files.") + } + + static func installingCannotBeCanceled() async throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = try archiveData(in: directory) + let commands = InstallationCommands(blocksInstallation: true) + var installed = false + let store = AppInstallationStore( + installer: AppInstaller( + download: { _, destination, _ in + try data.write(to: destination) + }, command: commands.run, temporaryDirectory: directory.appending(component: "work")), + didInstall: { _, _ in installed = true }) + let app = InstallationFixtures.app() + let device = InstallationFixtures.device + store.install(app: app, version: InstallationFixtures.version(data: data), on: device) + try InstallationFixtures.require( + await Task.detached { commands.waitForStart() }.value, + "Wait until simctl starts installing.") + store.cancel(appID: app.id, deviceID: device.id) + try InstallationFixtures.require( + store.phase(appID: app.id, deviceID: device.id) == .installing, + "Once simctl starts, allow installation to finish.") + commands.finish.signal() + try await InstallationFixtures.eventually { + store.phase(appID: app.id, deviceID: device.id) == nil + } + try InstallationFixtures.require( + installed, "Record a confirmed installation even after a late cancellation request.") + } + + static func stoppedDevice() async throws { + let stopped = SimulatorDevice(id: "stopped", name: "iPhone", runtime: "27.0", state: "Shutdown") + let commands = InstallationCommands() + let store = AppInstallationStore( + installer: AppInstaller( + download: { _, _, _ in throw InstallationFixtures.failure("Unexpected download") }, + command: commands.run)) + let app = InstallationFixtures.app() + store.install(app: app, version: app.latestVersion, on: stopped) + try await InstallationFixtures.eventually { + store.phase(appID: app.id, deviceID: stopped.id) == nil + } + try InstallationFixtures.require( + store.error(appID: app.id, deviceID: stopped.id) + == SimulatorApplicationsError.notRunning.localizedDescription && commands.calls.isEmpty, + "A stopped simulator must fail before downloading or running simctl.") + store.open(app: app, on: stopped) + try await InstallationFixtures.eventually { + store.phase(appID: app.id, deviceID: stopped.id) == nil + } + try InstallationFixtures.require( + store.error(appID: app.id, deviceID: stopped.id) != nil && commands.calls.isEmpty, + "Report launch failures without claiming success.") + } + + static func archiveData(in directory: URL) throws -> Data { + try Data( + contentsOf: InstallationFixtures.archive(InstallationFixtures.entries(), in: directory)) + } +} + +private final class InstallationCommands: @unchecked Sendable { + let started = DispatchSemaphore(value: 0) + let finish = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var recorded: [[String]] = [] + private let blocksExtraction: Bool + private let blocksInstallation: Bool + private let failsInstallation: Bool + + init( + blocksExtraction: Bool = false, blocksInstallation: Bool = false, + failsInstallation: Bool = false + ) { + self.blocksExtraction = blocksExtraction + self.blocksInstallation = blocksInstallation + self.failsInstallation = failsInstallation + } + + var calls: [[String]] { lock.withLock { recorded } } + + func waitForStart() -> Bool { started.wait(timeout: .now() + 5) == .success } + + func run(_ executable: String, _ arguments: [String]) throws -> Data { + try InstallationFixtures.require( + !Thread.isMainThread, "Package commands and simctl must not block the UI.") + lock.withLock { recorded.append(arguments) } + if executable == "/usr/bin/tar" { + if blocksExtraction && arguments.first == "-tjf" { try block() } + return try Command.run(executable, arguments: arguments) + } + try InstallationFixtures.require( + executable == "/usr/bin/xcrun" && arguments.first == "simctl", + "Use the common simctl wrapper.") + if arguments[1] == "install" { + try InstallationFixtures.require( + FileManager.default.fileExists(atPath: arguments[3] + "/Info.plist"), + "Keep the verified app until simctl finishes.") + if blocksInstallation { try block() } + if failsInstallation { throw InstallationFixtures.failure("Install failed") } + } + return Data("org.example.App: 1234\n".utf8) + } + + private func block() throws { + started.signal() + try InstallationFixtures.require( + finish.wait(timeout: .now() + 5) == .success, "Release the blocked command.") + } +} + +private actor InstallationDownloadGate { + private let data: Data + private var pending: [Int: CheckedContinuation] = [:] + private var progress: [AppDownload.Progress] = [] + var count: Int { progress.count } + + init(data: Data) { self.data = data } + + func download(_ url: URL, _ destination: URL, _ callback: @escaping AppDownload.Progress) + async throws + { + let index = count + progress.append(callback) + await withCheckedContinuation { pending[index] = $0 } + try data.write(to: destination) + } + + func finish(_ index: Int) { pending.removeValue(forKey: index)?.resume() } + func report(_ index: Int, bytes: Int64) { progress[index](bytes) } + + func waitForCount(_ expected: Int) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while count < expected { + guard ContinuousClock.now < deadline else { + throw InstallationFixtures.failure("A download did not start.") + } + try await Task.sleep(for: .milliseconds(1)) + } + } +} diff --git a/tests/AppPackageTests.swift b/tests/AppPackageTests.swift new file mode 100644 index 0000000..45e41af --- /dev/null +++ b/tests/AppPackageTests.swift @@ -0,0 +1,174 @@ +import Foundation + +enum AppPackageTests { + static func run() throws { + try checksums() + try validArchiveAndBundle() + try invalidArchives() + try invalidBundles() + try frameworks() + } + + static func checksums() throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let data = Data(repeating: 0x42, count: 2 * 1024 * 1024 + 7) + let path = directory.appending(component: "archive") + try data.write(to: path) + try AppPackage.verify(path, version: InstallationFixtures.version(data: data)) + try expectFailure { + try AppPackage.verify( + path, version: InstallationFixtures.version(data: Data(data.dropLast()))) + } + var changed = data + changed[changed.count - 1] = 0 + try changed.write(to: path) + try expectFailure { + try AppPackage.verify(path, version: InstallationFixtures.version(data: data)) + } + } + + static func validArchiveAndBundle() throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let entries = try InstallationFixtures.entries().map { entry in + var entry = entry + entry = InstallationFixtures.Entry( + path: entry.path.replacingOccurrences(of: "Example.app", with: "An App 名称.app"), + data: entry.data, type: entry.type, target: entry.target, mode: entry.mode) + return entry + } + let archive = try InstallationFixtures.archive(entries, in: directory) + let app = try AppPackage.extract(archive, to: directory.appending(component: "extracted")) + try AppPackage.validate( + app, app: InstallationFixtures.app(), version: InstallationFixtures.version()) + try InstallationFixtures.require( + app.lastPathComponent == "An App 名称.app", "Keep app filenames intact.") + } + + static func invalidArchives() throws { + let directory = try InstallationFixtures.temporaryDirectory(in: URL(fileURLWithPath: "/tmp")) + defer { try? FileManager.default.removeItem(at: directory) } + let marker = directory.appending(component: "outside") + let original = Data("Keep outside files unchanged".utf8) + try original.write(to: marker) + let attacks: [[InstallationFixtures.Entry]] = [ + [], + [.init(path: "../outside", data: Data("replaced".utf8))], + [.init(path: marker.path, data: Data("replaced".utf8))], + [.init(path: "Example.app/", type: 53), .init(path: "other.txt")], + [.init(path: "Example.app", type: 50, target: directory.path)], + [ + .init(path: "Example.app/link", type: 50, target: directory.path), + .init(path: "Example.app/link/outside", data: Data("replaced".utf8)), + ], + [ + .init(path: "Example.app/outside", type: 49, target: "../outside"), + .init(path: "Example.app/outside", data: Data("replaced".utf8)), + ], + ] + for (index, entries) in attacks.enumerated() { + let archive = try InstallationFixtures.archive(entries, in: directory) + try expectFailure { + _ = try AppPackage.extract(archive, to: directory.appending(component: "attack-\(index)")) + } + try InstallationFixtures.require( + try Data(contentsOf: marker) == original, "Do not modify files outside extraction.") + } + } + + static func invalidBundles() throws { + typealias Mutation = (URL) throws -> Void + let mutations: [Mutation] = [ + { + try InstallationFixtures.info(bundleID: "org.example.Other").write( + to: $0.appending(path: "Info.plist")) + }, + { try InstallationFixtures.info(version: "2.0").write(to: $0.appending(path: "Info.plist")) }, + { + try InstallationFixtures.info(platform: "iPhoneOS").write( + to: $0.appending(path: "Info.plist")) + }, + { + try InstallationFixtures.info(executable: "../Example").write( + to: $0.appending(path: "Info.plist")) + }, + { + try InstallationFixtures.executable( + InstallationFixtures.machO(platform: 2), at: $0.appending(path: "Example")) + }, + { + try InstallationFixtures.executable( + InstallationFixtures.machO(cpu: 0x0100_0007), at: $0.appending(path: "Example")) + }, + { + try InstallationFixtures.executable( + Data([0xCA, 0xFE, 0xBA, 0xBE]), at: $0.appending(path: "Example")) + }, + { + try InstallationFixtures.executable( + Data("not Mach-O".utf8), at: $0.appending(path: "Example")) + }, + { + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], ofItemAtPath: $0.appending(path: "Example").path) + }, + { bundle in + var data = InstallationFixtures.machO() + data[36] = 0xFF + try InstallationFixtures.executable(data, at: bundle.appending(path: "Example")) + }, + { + try InstallationFixtures.machO(platform: 1, type: 6).write( + to: $0.appending(path: "hidden-library")) + }, + { + try FileManager.default.createSymbolicLink( + atPath: $0.appending(path: "escape").path, withDestinationPath: "/tmp") + }, + ] + for mutate in mutations { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let bundle = try InstallationFixtures.bundle(in: directory) + try mutate(bundle) + try expectFailure { + try AppPackage.validate( + bundle, app: InstallationFixtures.app(), version: InstallationFixtures.version()) + } + } + } + + static func frameworks() throws { + let directory = try InstallationFixtures.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let bundle = try InstallationFixtures.bundle(in: directory) + let framework = bundle.appending(path: "Frameworks/Test.framework") + let resources = framework.appending(path: "Versions/A/Resources") + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + try InstallationFixtures.info(executable: "Test").write( + to: resources.appending(path: "Info.plist")) + let binary = framework.appending(path: "Versions/A/Test") + try InstallationFixtures.executable(InstallationFixtures.machO(type: 6), at: binary) + for (path, target) in [ + ("Versions/Current", "A"), ("Test", "Versions/Current/Test"), + ("Resources", "Versions/Current/Resources"), + ] { + try FileManager.default.createSymbolicLink( + atPath: framework.appending(path: path).path, withDestinationPath: target) + } + try AppPackage.validate( + bundle, app: InstallationFixtures.app(), version: InstallationFixtures.version()) + try InstallationFixtures.executable( + InstallationFixtures.machO(platform: 2, type: 6), at: binary) + try expectFailure { + try AppPackage.validate( + bundle, app: InstallationFixtures.app(), version: InstallationFixtures.version()) + } + } + + static func expectFailure(_ operation: () throws -> Void) throws { + do { try operation() } catch { return } + throw InstallationFixtures.failure("Invalid input must fail before installation.") + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 15f2493..fde20f6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -39,6 +39,22 @@ target_compile_options(SimStoreIconCacheTest PRIVATE -parse-as-library) add_test(NAME icon-cache COMMAND SimStoreIconCacheTest) set_tests_properties(icon-cache PROPERTIES TIMEOUT 20) +add_executable(SimStoreInstallationTest AppInstallationTests.swift AppDownloadTests.swift AppPackageTests.swift InstallationFixtures.swift + "${PROJECT_SOURCE_DIR}/src/AppCatalog.swift" + "${PROJECT_SOURCE_DIR}/src/AppConfiguration.swift" + "${PROJECT_SOURCE_DIR}/src/AppDownload.swift" + "${PROJECT_SOURCE_DIR}/src/AppPackage.swift" + "${PROJECT_SOURCE_DIR}/src/AppInstaller.swift" + "${PROJECT_SOURCE_DIR}/src/AppInstallationStore.swift" + "${PROJECT_SOURCE_DIR}/src/InstalledAppsStore.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorApplications.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" + "${PROJECT_SOURCE_DIR}/src/Command.swift" +) +target_compile_options(SimStoreInstallationTest PRIVATE -parse-as-library) +add_test(NAME installation COMMAND SimStoreInstallationTest) +set_tests_properties(installation PROPERTIES TIMEOUT 30) + add_executable(SimStoreDiscoveryTest SimulatorDiscoveryTests.swift "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift" "${PROJECT_SOURCE_DIR}/src/Command.swift" diff --git a/tests/InstallationFixtures.swift b/tests/InstallationFixtures.swift new file mode 100644 index 0000000..62d57fe --- /dev/null +++ b/tests/InstallationFixtures.swift @@ -0,0 +1,131 @@ +import CryptoKit +import Foundation + +enum InstallationFixtures { + static let device = SimulatorDevice( + id: "device-A", name: "iPhone", runtime: "27.0", state: "Booted") + + static func temporaryDirectory(in parent: URL = .temporaryDirectory) throws -> URL { + let directory = parent.appending(component: "installation-test-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + static func version(data: Data = Data(), number: String = "1.0.0") -> CatalogVersion { + CatalogVersion( + version: number, minSimulatorOS: "15.0", + sha256: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), + size: Int64(data.count), revision: String(repeating: "a", count: 40), + recipeRevision: String(repeating: "b", count: 40), time: 0) + } + + static func app(version: CatalogVersion = version()) -> CatalogApp { + CatalogApp( + id: "example", name: "Example", bundleID: "org.example.App", + repository: URL(string: "https://github.com/example/app")!, license: "MIT", + description: ["en": "An example app."], categories: ["utilities"], versions: [version]) + } + + static func info( + bundleID: String = "org.example.App", version: String = "1.0", executable: String = "Example", + platform: String = "iPhoneSimulator" + ) throws -> Data { + try PropertyListSerialization.data( + fromPropertyList: [ + "CFBundleIdentifier": bundleID, "CFBundleShortVersionString": version, + "CFBundleExecutable": executable, "MinimumOSVersion": "15.0", + "CFBundleSupportedPlatforms": [platform], + ], format: .binary, options: 0) + } + + static func machO(platform: UInt32 = 7, cpu: UInt32 = 0x0100_000C, type: UInt32 = 2) -> Data { + let words: [UInt32] = [0xFEED_FACF, cpu, 0, type, 1, 24, 0, 0, 0x32, 24, platform, 0, 0, 0] + return words.reduce(into: Data()) { data, word in + var value = word.littleEndian + withUnsafeBytes(of: &value) { data.append(contentsOf: $0) } + } + } + + static func bundle(in directory: URL) throws -> URL { + let bundle = directory.appending(component: "Example.app") + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + try info().write(to: bundle.appending(component: "Info.plist")) + try executable(machO(), at: bundle.appending(component: "Example")) + return bundle + } + + static func executable(_ data: Data, at path: URL) throws { + try data.write(to: path) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) + } + + struct Entry { + let path: String + var data = Data() + var type: UInt8 = 48 + var target = "" + var mode = 0o644 + } + + static func entries() throws -> [Entry] { + [ + Entry(path: "Example.app/", type: 53, mode: 0o755), + Entry(path: "Example.app/Info.plist", data: try info()), + Entry(path: "Example.app/Example", data: machO(), mode: 0o755), + ] + } + + static func archive(_ entries: [Entry], in directory: URL) throws -> URL { + var result = Data() + for entry in entries { + var header = Data(repeating: 0, count: 512) + func set(_ string: String, at offset: Int, length: Int) { + let bytes = Data(string.utf8) + precondition(bytes.count <= length) + header.replaceSubrange(offset..<(offset + bytes.count), with: bytes) + } + set(entry.path, at: 0, length: 100) + set(String(format: "%07o", entry.mode), at: 100, length: 8) + set("0000000", at: 108, length: 8) + set("0000000", at: 116, length: 8) + set(String(format: "%011o", entry.data.count), at: 124, length: 12) + set("00000000000", at: 136, length: 12) + set(" ", at: 148, length: 8) + header[156] = entry.type + set(entry.target, at: 157, length: 100) + set("ustar", at: 257, length: 6) + set("00", at: 263, length: 2) + let sum = header.reduce(0) { $0 + Int($1) } + set(String(format: "%06o\0 ", sum), at: 148, length: 8) + result.append(header) + result.append(entry.data) + result.append(Data(repeating: 0, count: (512 - entry.data.count % 512) % 512)) + } + result.append(Data(repeating: 0, count: 1024)) + let tar = directory.appending(component: "fixture.tar") + try result.write(to: tar) + let compressed = try Command.run("/usr/bin/bzip2", arguments: ["-c", tar.path]) + let archive = directory.appending(component: "fixture.tar.bz2") + try compressed.write(to: archive) + return archive + } + + static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw failure(message) } + } + + static func failure(_ message: String) -> NSError { + NSError(domain: "InstallationTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } + + @MainActor + static func eventually(_ condition: () -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !condition() { + guard ContinuousClock.now < deadline else { + throw failure("An installation operation timed out.") + } + try await Task.sleep(for: .milliseconds(1)) + } + } +} diff --git a/tests/InstalledAppsStoreTests.swift b/tests/InstalledAppsStoreTests.swift index 4b7eee6..b8f9d53 100644 --- a/tests/InstalledAppsStoreTests.swift +++ b/tests/InstalledAppsStoreTests.swift @@ -10,6 +10,7 @@ struct InstalledAppsStoreTests { try await staleErrors() try await cancellationAndCoalescing() try await refreshedDeviceState() + try await recordingInstallationDuringRefresh() print( "PASS: per-device installed apps, filtering, stale responses, errors, and cancellation.") } catch { @@ -169,6 +170,32 @@ struct InstalledAppsStoreTests { SimulatorDevice(id: id, name: "iPhone 17 Pro", runtime: "27.0", state: state) } + static func recordingInstallationDuringRefresh() async throws { + let probe = InventoryProbe() + let store = InstalledAppsStore(load: { try await probe.load($0) }) + let first = Task { await store.refresh(for: device("A")) } + try await eventually { await probe.calls.count == 1 } + store.recordInstallation(bundleID: "org.example.WrongDevice", deviceID: "B") + store.recordInstallation(bundleID: "org.example.New", deviceID: "A") + try require( + store.bundleIDs == nil, + "An installation must not turn an unknown inventory into a complete snapshot.") + await probe.finish(0, with: .success([])) + await first.value + try require( + store.bundleIDs == ["org.example.New"], + "Merge successful installs into an older in-flight inventory response.") + try require( + store.isInstalled(bundleID: "org.example.NEW", deviceID: "A") == true, + "Match installed bundle IDs without case sensitivity.") + let next = Task { await store.refresh(for: device("A")) } + try await eventually { await probe.calls.count == 2 } + await probe.finish(1, with: .success([])) + await next.value + try require( + store.bundleIDs == [], "A later authoritative refresh must reflect external uninstalls.") + } + static func catalog() -> AppCatalog { let version = CatalogVersion( version: "1.0.0", minSimulatorOS: "16.0", sha256: String(repeating: "a", count: 64),