diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index dbf3ff6..b54e469 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -1601,6 +1601,22 @@ } } }, + "The simulator application could not be found. Check your Xcode installation.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The simulator application could not be found. Check your Xcode installation." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到模拟器程序,请检查 Xcode 安装。" + } + } + } + }, "The simulator returned an invalid app list.": { "localizations": { "en": { diff --git a/src/AppInstallButton.swift b/src/AppInstallButton.swift index 42f70f4..c4dd3d6 100644 --- a/src/AppInstallButton.swift +++ b/src/AppInstallButton.swift @@ -7,6 +7,7 @@ struct AppInstallButton: View { let store: AppInstallationStore let isInstalled: Bool? var allowsIncompatible = false + var isDefaultAction = false private enum Action: CaseIterable { case install, open, cancel, installing, canceling, opening @@ -44,6 +45,14 @@ struct AppInstallButton: View { } var body: some View { + if phase == nil { + button.buttonStyle(.borderedProminent) + } else { + button.buttonStyle(.bordered) + } + } + + private var button: some View { Button { switch action { case .install: store.install(app: app, version: version, on: device) @@ -58,6 +67,7 @@ struct AppInstallButton: View { .lineLimit(1) } .disabled(!enabled) + .keyboardShortcut(isDefaultAction && phase == nil ? .defaultAction : nil) .accessibilityLabel(Text(verbatim: "\(action.title) \(app.name)")) .accessibilityValue(Text(verbatim: phase?.summary(totalBytes: version.size) ?? "")) .help(Text(verbatim: version.minimumOSLabel)) @@ -74,14 +84,7 @@ struct AppInstallStatusView: 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) + DownloadProgressView(receivedBytes: bytes, totalBytes: version.size, title: phase.title) } else { HStack(spacing: 10) { ProgressView().controlSize(.small) diff --git a/src/AppInstallationStore.swift b/src/AppInstallationStore.swift index 30a56a7..79c0cf7 100644 --- a/src/AppInstallationStore.swift +++ b/src/AppInstallationStore.swift @@ -11,17 +11,24 @@ final class AppInstallationStore { private var phases: [Key: AppInstallationPhase] = [:] private var errors: [Key: String] = [:] + private(set) var openedApp: Key? @ObservationIgnored private let installer: AppInstaller @ObservationIgnored private let didInstall: @MainActor (String, String) -> Void + @ObservationIgnored private let showDevice: @MainActor (SimulatorDevice) async throws -> Void + @ObservationIgnored private let didOpen: @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 } + showDevice: @escaping @MainActor (SimulatorDevice) async throws -> Void, + didInstall: @escaping @MainActor (String, String) -> Void = { _, _ in }, + didOpen: @escaping @MainActor (String, String) -> Void = { _, _ in } ) { self.installer = installer self.didInstall = didInstall + self.showDevice = showDevice + self.didOpen = didOpen } func phase(appID: String, deviceID: String) -> AppInstallationPhase? { @@ -70,12 +77,16 @@ final class AppInstallationStore { func open(app: CatalogApp, on device: SimulatorDevice) { let key = Key(appID: app.id, deviceID: device.id) guard jobs[key] == nil else { return } + openedApp = nil errors[key] = nil phases[key] = .opening jobs[key] = Task { defer { finish(key) } do { try await installer.open(app: app, on: device) + try await showDevice(device) + didOpen(app.bundleID, device.id) + openedApp = key } catch { errors[key] = error.localizedDescription } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3ee9d09..a9a8553 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(SimStore MACOSX_BUNDLE SimStoreApp.swift SimulatorDiscovery.swift SimulatorControl.swift + SimulatorPresenter.swift SimulatorApplications.swift InstalledAppsStore.swift InstalledAppsStatusView.swift @@ -19,6 +20,7 @@ add_executable(SimStore MACOSX_BUNDLE AppInstaller.swift AppInstallationStore.swift AppInstallButton.swift + DownloadProgressView.swift CatalogClient.swift CatalogStore.swift CatalogPresentation.swift diff --git a/src/CatalogAppCard.swift b/src/CatalogAppCard.swift index 3270c68..9fee25b 100644 --- a/src/CatalogAppCard.swift +++ b/src/CatalogAppCard.swift @@ -61,7 +61,6 @@ struct CatalogAppCard: View { 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)") } diff --git a/src/CatalogAppDetailView.swift b/src/CatalogAppDetailView.swift index 0d7ec66..c61c667 100644 --- a/src/CatalogAppDetailView.swift +++ b/src/CatalogAppDetailView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI @MainActor @@ -7,6 +8,8 @@ struct CatalogAppDetailView: View { let installedApps: InstalledAppsStore let installations: AppInstallationStore @State private var versionID: String + @State private var showsBuildDetails = false + @State private var collapsedContentHeight: CGFloat = 560 @Environment(\.dismiss) private var dismiss init( @@ -26,6 +29,13 @@ struct CatalogAppDetailView: View { app.versions.first(where: { $0.version == versionID }) ?? app.recommendedVersion(for: runtime) } + private var contentHeight: CGFloat { + let screen = NSApp.keyWindow?.screen ?? NSApp.mainWindow?.screen ?? NSScreen.main + // Reserve space for the footer, window chrome, and screen edges. + let available = (screen?.visibleFrame.height ?? 900) - 160 + return min(collapsedContentHeight.rounded(.up), available) + } + var body: some View { VStack(spacing: 0) { ScrollView { @@ -113,7 +123,7 @@ struct CatalogAppDetailView: View { } } - DisclosureGroup("Build Details") { + DisclosureGroup("Build Details", isExpanded: $showsBuildDetails) { VStack(alignment: .leading, spacing: 12) { buildDetail( "Source Revision", value: version.revision, @@ -138,6 +148,15 @@ struct CatalogAppDetailView: View { .fixedSize(horizontal: false, vertical: true) } .padding(28) + .background { + GeometryReader { geometry in + Color.clear.preference(key: DetailContentHeight.self, value: geometry.size.height) + } + } + } + .frame(height: contentHeight) + .onPreferenceChange(DetailContentHeight.self) { height in + if !showsBuildDetails && height > 0 { collapsedContentHeight = height } } Divider() HStack { @@ -146,15 +165,13 @@ struct CatalogAppDetailView: View { AppInstallButton( app: app, version: version, device: device, store: installations, isInstalled: installedApps.isInstalled(bundleID: app.bundleID, deviceID: device.id), - allowsIncompatible: true + allowsIncompatible: true, isDefaultAction: true ) - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) .accessibilityIdentifier("detail-install") } .padding(20) } - .frame(width: 580, height: 560) + .frame(width: 580) } private func buildDetail(_ title: LocalizedStringKey, value: String, link: URL? = nil) @@ -175,6 +192,14 @@ struct CatalogAppDetailView: View { } } +private struct DetailContentHeight: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + private struct DetailsDisclosureStyle: DisclosureGroupStyle { func makeBody(configuration: Configuration) -> some View { VStack(alignment: .leading, spacing: 0) { diff --git a/src/CatalogBrowserView.swift b/src/CatalogBrowserView.swift index 9f9af25..8ac0754 100644 --- a/src/CatalogBrowserView.swift +++ b/src/CatalogBrowserView.swift @@ -23,7 +23,7 @@ struct CatalogBrowserView: View { private var availableApps: [CatalogApp]? { guard device.state == "Booted", let catalog = store.catalog else { return nil } - return installedApps.uninstalledApps(in: catalog, for: device.id) + return installedApps.discoverableApps(in: catalog, for: device.id) } private var apps: [CatalogApp] { @@ -81,6 +81,11 @@ struct CatalogBrowserView: View { .padding(.top, 28) .task { await store.refresh() } .task(id: device) { await installedApps.refresh(for: device) } + .onChange(of: installations.openedApp) { _, opened in + if let opened, opened.deviceID == device.id, selectedApp?.id == opened.appID { + selectedApp = nil + } + } .onChange(of: categories) { _, values in guard availableApps != nil else { return } if !category.isEmpty && !values.contains(category) { category = "" } @@ -227,6 +232,7 @@ struct CatalogBrowserView: View { private func refreshInstalledApps() { Task { await installedApps.refresh(for: device) } } private func refresh() { + installedApps.clearRecentInstallations() Task { await store.refresh() } refreshInstalledApps() } diff --git a/src/ContentView.swift b/src/ContentView.swift index 4129bec..5240d80 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -27,19 +27,32 @@ struct ContentView: View { }, catalogClient: CatalogClient = .live, installer: AppInstaller = AppInstaller(), + showDevice: (@MainActor (SimulatorDevice) async throws -> Void)? = nil, loadInstalledApps: @escaping @Sendable (SimulatorDevice) async throws -> Set = { try await SimulatorApplications.installedBundleIDs(on: $0) } ) { - _simulators = State(initialValue: SimulatorStore(discover: discover, boot: boot)) + let simulators = SimulatorStore(discover: discover, boot: boot) + _simulators = State(initialValue: simulators) _catalog = State(initialValue: CatalogStore(client: catalogClient)) let installedApps = InstalledAppsStore(load: loadInstalledApps) _installedApps = State(initialValue: installedApps) _installations = State( initialValue: AppInstallationStore( installer: installer, + showDevice: { device in + if let showDevice { + try await showDevice(device) + } else { + try await SimulatorPresenter.show( + deviceID: device.id, manager: simulators.discovery?.deviceManager) + } + }, didInstall: { bundleID, deviceID in installedApps.recordInstallation(bundleID: bundleID, deviceID: deviceID) + }, + didOpen: { bundleID, deviceID in + installedApps.removeRecentInstallation(bundleID: bundleID, deviceID: deviceID) })) } diff --git a/src/DownloadProgressView.swift b/src/DownloadProgressView.swift new file mode 100644 index 0000000..3798a19 --- /dev/null +++ b/src/DownloadProgressView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct DownloadProgressView: View { + let receivedBytes: Int64 + let totalBytes: Int64 + let title: String + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ProgressView( + value: Double(max(0, min(receivedBytes, totalBytes))), total: Double(totalBytes) + ) { + Text(verbatim: title) + } + HStack(alignment: .firstTextBaseline) { + Text(verbatim: ByteCountFormatter.string(fromByteCount: receivedBytes, countStyle: .file)) + .accessibilityIdentifier("download-received") + Spacer(minLength: 8) + Text( + verbatim: "/ \(ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file))" + ) + .fixedSize() + .accessibilityIdentifier("download-total") + } + .font(.system(size: 11)).monospacedDigit().foregroundStyle(.secondary) + } + } +} diff --git a/src/InstalledAppsStore.swift b/src/InstalledAppsStore.swift index 126dd54..d3da241 100644 --- a/src/InstalledAppsStore.swift +++ b/src/InstalledAppsStore.swift @@ -8,6 +8,7 @@ final class InstalledAppsStore { private(set) var bundleIDs: Set? private(set) var isLoading = false private(set) var error: String? + private var recentInstallations: Set = [] @ObservationIgnored private let load: @Sendable (SimulatorDevice) async throws -> Set @@ -26,15 +27,26 @@ final class InstalledAppsStore { self.load = load } - func uninstalledApps(in catalog: AppCatalog, for deviceID: String) -> [CatalogApp]? { + func discoverableApps(in catalog: AppCatalog, for deviceID: String) -> [CatalogApp]? { guard self.deviceID == deviceID, let bundleIDs else { return nil } let installed = Set(bundleIDs.map { $0.lowercased() }) - return catalog.apps.filter { !installed.contains($0.bundleID.lowercased()) } + return catalog.apps.filter { + !installed.contains($0.bundleID.lowercased()) + || recentInstallations.contains($0.bundleID.lowercased()) + } + } + + func clearRecentInstallations() { recentInstallations = [] } + + func removeRecentInstallation(bundleID: String, deviceID: String) { + guard self.deviceID == deviceID else { return } + recentInstallations.remove(bundleID.lowercased()) } func recordInstallation(bundleID: String, deviceID: String) { guard self.deviceID == deviceID else { return } installedDuringRefresh.insert(bundleID) + recentInstallations.insert(bundleID.lowercased()) bundleIDs?.insert(bundleID) } @@ -53,6 +65,7 @@ final class InstalledAppsStore { return } let pending = Request(device: device) + if selectedDevice?.id != device.id { clearRecentInstallations() } request = pending selectedDevice = device deviceID = device.id diff --git a/src/SimulatorPresenter.swift b/src/SimulatorPresenter.swift new file mode 100644 index 0000000..877430a --- /dev/null +++ b/src/SimulatorPresenter.swift @@ -0,0 +1,47 @@ +import AppKit + +enum SimulatorPresenter { + struct Request: Equatable, Sendable { + let applicationURL: URL + let urls: [URL] + let arguments: [String] + } + + static func request(deviceID: String, manager: (name: String, url: URL)?) throws -> Request { + guard let manager else { + throw NSError( + domain: "SimulatorPresenter", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: String( + localized: + "The simulator application could not be found. Check your Xcode installation.") + ]) + } + if manager.url.lastPathComponent == "DeviceHub.app" { + var components = URLComponents() + components.scheme = "devices" + components.host = "device" + components.path = "/open" + components.queryItems = [URLQueryItem(name: "id", value: deviceID)] + return Request(applicationURL: manager.url, urls: [components.url!], arguments: []) + } + return Request( + applicationURL: manager.url, urls: [], arguments: ["-CurrentDeviceUDID", deviceID]) + } + + @MainActor + static func show(deviceID: String, manager: (name: String, url: URL)?) async throws { + let request = try request(deviceID: deviceID, manager: manager) + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + configuration.allowsRunningApplicationSubstitution = false + configuration.arguments = request.arguments + if request.urls.isEmpty { + _ = try await NSWorkspace.shared.openApplication( + at: request.applicationURL, configuration: configuration) + } else { + _ = try await NSWorkspace.shared.open( + request.urls, withApplicationAt: request.applicationURL, configuration: configuration) + } + } +} diff --git a/tests/AppInstallationTests.swift b/tests/AppInstallationTests.swift index 7508efa..ac9ed1a 100644 --- a/tests/AppInstallationTests.swift +++ b/tests/AppInstallationTests.swift @@ -10,6 +10,7 @@ struct AppInstallationTests { try await pipeline() try await failureAndCleanup() try await perDeviceJobs() + try await presentationFailures() try await cancellationDuringPreparation() try await installingCannotBeCanceled() try await stoppedDevice() @@ -108,11 +109,20 @@ struct AppInstallationTests { let commands = InstallationCommands() let inventory = InstalledAppsStore(load: { _ in [] }) await inventory.refresh(for: InstallationFixtures.device) + var shownDevices: [SimulatorDevice] = [] + var openedDevices: [String] = [] 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) }) + showDevice: { device in + try InstallationFixtures.require( + commands.calls.last == ["simctl", "launch", device.id, "org.example.App"], + "Present the target device only after its app has launched.") + shownDevices.append(device) + }, + didInstall: { inventory.recordInstallation(bundleID: $0, deviceID: $1) }, + didOpen: { _, deviceID in openedDevices.append(deviceID) }) let version = InstallationFixtures.version(data: data) let app = InstallationFixtures.app(version: version) let a = InstallationFixtures.device @@ -166,6 +176,39 @@ struct AppInstallationTests { try InstallationFixtures.require( commands.calls.last == ["simctl", "launch", b.id, app.bundleID], "Launch on the requested device.") + try InstallationFixtures.require(shownDevices == [b], "Present the original launch target.") + try InstallationFixtures.require( + openedDevices == [b.id] && store.openedApp == .init(appID: app.id, deviceID: b.id), + "Publish a successful open only after presenting its target.") + } + + static func presentationFailures() async throws { + for launchFails in [true, false] { + let commands = InstallationCommands(failsLaunch: launchFails) + var didShow = false + var didOpen = false + let store = AppInstallationStore( + installer: AppInstaller(command: commands.run), + showDevice: { _ in + didShow = true + throw InstallationFixtures.failure("Presentation failed") + }, didOpen: { _, _ in didOpen = true }) + let app = InstallationFixtures.app() + let device = InstallationFixtures.device + store.open(app: app, on: device) + try await InstallationFixtures.eventually { + store.phase(appID: app.id, deviceID: device.id) == nil + } + try InstallationFixtures.require( + didShow != launchFails, "Do not change apps after a failed launch.") + try InstallationFixtures.require( + !didOpen && store.openedApp == nil, + "A failed launch or presentation must preserve the card and details for retry.") + try InstallationFixtures.require( + store.error(appID: app.id, deviceID: device.id) + == (launchFails ? "Launch failed" : "Presentation failed"), + "Preserve launch and presentation failures and permit retry.") + } } static func cancellationDuringPreparation() async throws { @@ -178,7 +221,7 @@ struct AppInstallationTests { installer: AppInstaller( download: { _, destination, _ in try data.write(to: destination) - }, command: commands.run, temporaryDirectory: work)) + }, command: commands.run, temporaryDirectory: work), showDevice: { _ in }) let app = InstallationFixtures.app() let device = InstallationFixtures.device store.install(app: app, version: InstallationFixtures.version(data: data), on: device) @@ -209,6 +252,7 @@ struct AppInstallationTests { download: { _, destination, _ in try data.write(to: destination) }, command: commands.run, temporaryDirectory: directory.appending(component: "work")), + showDevice: { _ in }, didInstall: { _, _ in installed = true }) let app = InstallationFixtures.app() let device = InstallationFixtures.device @@ -234,7 +278,7 @@ struct AppInstallationTests { let store = AppInstallationStore( installer: AppInstaller( download: { _, _, _ in throw InstallationFixtures.failure("Unexpected download") }, - command: commands.run)) + command: commands.run), showDevice: { _ in }) let app = InstallationFixtures.app() store.install(app: app, version: app.latestVersion, on: stopped) try await InstallationFixtures.eventually { @@ -267,14 +311,16 @@ private final class InstallationCommands: @unchecked Sendable { private let blocksExtraction: Bool private let blocksInstallation: Bool private let failsInstallation: Bool + private let failsLaunch: Bool init( blocksExtraction: Bool = false, blocksInstallation: Bool = false, - failsInstallation: Bool = false + failsInstallation: Bool = false, failsLaunch: Bool = false ) { self.blocksExtraction = blocksExtraction self.blocksInstallation = blocksInstallation self.failsInstallation = failsInstallation + self.failsLaunch = failsLaunch } var calls: [[String]] { lock.withLock { recorded } } @@ -299,6 +345,7 @@ private final class InstallationCommands: @unchecked Sendable { if blocksInstallation { try block() } if failsInstallation { throw InstallationFixtures.failure("Install failed") } } + if arguments[1] == "launch", failsLaunch { throw InstallationFixtures.failure("Launch failed") } return Data("org.example.App: 1234\n".utf8) } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fde20f6..58ed554 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -65,6 +65,7 @@ set_tests_properties(simulator-discovery PROPERTIES TIMEOUT 15) add_executable(SimStoreControlTest SimulatorControlTests.swift "${PROJECT_SOURCE_DIR}/src/Command.swift" "${PROJECT_SOURCE_DIR}/src/SimulatorControl.swift" + "${PROJECT_SOURCE_DIR}/src/SimulatorPresenter.swift" ) target_compile_options(SimStoreControlTest PRIVATE -parse-as-library) add_test(NAME simulator-control COMMAND SimStoreControlTest) diff --git a/tests/InstalledAppsStoreTests.swift b/tests/InstalledAppsStoreTests.swift index b8f9d53..69e9a16 100644 --- a/tests/InstalledAppsStoreTests.swift +++ b/tests/InstalledAppsStoreTests.swift @@ -11,6 +11,7 @@ struct InstalledAppsStoreTests { try await cancellationAndCoalescing() try await refreshedDeviceState() try await recordingInstallationDuringRefresh() + try await retainInstalledAppsForCurrentVisit() print( "PASS: per-device installed apps, filtering, stale responses, errors, and cancellation.") } catch { @@ -25,7 +26,7 @@ struct InstalledAppsStoreTests { let store = InstalledAppsStore(load: { try await probe.load($0) }) let selected = device("A") let catalog = catalog() - try require(store.uninstalledApps(in: catalog, for: "A") == nil, "Unknown is not uninstalled.") + try require(store.discoverableApps(in: catalog, for: "A") == nil, "Unknown is not uninstalled.") let initial = Task { await store.refresh(for: selected) } try await eventually { await probe.calls.count == 1 } try require( @@ -34,10 +35,10 @@ struct InstalledAppsStoreTests { await probe.finish(0, with: .success(["io.example.Installed", "io.example.UNKNOWN"])) await initial.value try require( - store.uninstalledApps(in: catalog, for: "A")?.map(\.id) == ["available"], + store.discoverableApps(in: catalog, for: "A")?.map(\.id) == ["available"], "Exclude installed apps by their case-insensitive Bundle ID, regardless of version.") try require( - store.uninstalledApps(in: catalog, for: "B") == nil, + store.discoverableApps(in: catalog, for: "B") == nil, "A snapshot may only filter its own simulator.") let failed = Task { await store.refresh(for: selected) } try await eventually { await probe.calls.count == 2 } @@ -53,14 +54,14 @@ struct InstalledAppsStoreTests { await retry.value try require( store.error == nil && store.bundleIDs == [] - && store.uninstalledApps(in: catalog, for: "A")?.count == catalog.apps.count, + && store.discoverableApps(in: catalog, for: "A")?.count == catalog.apps.count, "A confirmed empty installation set exposes the full catalog and clears the prior error.") let externalInstall = Task { await store.refresh(for: selected) } try await eventually { await probe.calls.count == 4 } await probe.finish(3, with: .success(["io.example.Available"])) await externalInstall.value try require( - store.uninstalledApps(in: catalog, for: "A")?.map(\.id) == ["installed", "case-variant"], + store.discoverableApps(in: catalog, for: "A")?.map(\.id) == ["installed", "case-variant"], "A later query must reflect apps installed outside SimStore.") } @@ -213,6 +214,45 @@ struct InstalledAppsStoreTests { return AppCatalog(schemaVersion: 1, apps: apps) } + static func retainInstalledAppsForCurrentVisit() async throws { + let current = catalog() + let store = InstalledAppsStore(load: { _ in ["io.example.Installed", "io.example.Available"] }) + await store.refresh(for: device("A")) + try require( + store.discoverableApps(in: current, for: "A")?.map(\.id) == ["case-variant"], + "Hide apps installed before this visit.") + store.recordInstallation(bundleID: "io.example.AVAILABLE", deviceID: "A") + try require( + store.discoverableApps(in: current, for: "A")?.map(\.id) == ["available", "case-variant"], + "Keep newly installed apps in their original catalog order.") + try require( + store.isInstalled(bundleID: "io.example.Available", deviceID: "A") == true, + "The retained card must offer Open.") + store.removeRecentInstallation(bundleID: "io.example.Available", deviceID: "B") + try require( + store.discoverableApps(in: current, for: "A")?.map(\.id) == ["available", "case-variant"], + "Opening on another device must not remove this device's card.") + await store.refresh(for: device("A")) + try require( + store.discoverableApps(in: current, for: "A")?.map(\.id) == ["available", "case-variant"], + "Automatic inventory refresh must retain the current visit.") + store.removeRecentInstallation(bundleID: "io.example.AVAILABLE", deviceID: "A") + try require( + store.discoverableApps(in: current, for: "A")?.map(\.id) == ["case-variant"], + "Remove a successfully opened app from the current visit.") + store.recordInstallation(bundleID: "io.example.Available", deviceID: "A") + store.clearRecentInstallations() + try require( + store.discoverableApps(in: current, for: "A")?.map(\.id) == ["case-variant"], + "Explicit catalog refresh clears retained installations.") + store.recordInstallation(bundleID: "io.example.Available", deviceID: "A") + await store.refresh(for: device("B")) + await store.refresh(for: device("A")) + try require( + store.discoverableApps(in: current, for: "A")?.map(\.id) == ["case-variant"], + "Switching simulators begins a new visit.") + } + static func eventually(_ condition: () async -> Bool) async throws { let deadline = ContinuousClock.now.advanced(by: .seconds(2)) while !(await condition()) { diff --git a/tests/SimulatorControlTests.swift b/tests/SimulatorControlTests.swift index 00c2a76..954edf0 100644 --- a/tests/SimulatorControlTests.swift +++ b/tests/SimulatorControlTests.swift @@ -9,6 +9,7 @@ struct SimulatorControlTests { try await commandFailure() try await cancelledBeforeBoot() try await cancelledDuringBoot() + try presentationRequests() print("PASS: simulator boot command, background execution, command errors, and cancellation.") } catch { FileHandle.standardError.write( @@ -88,6 +89,37 @@ struct SimulatorControlTests { semaphore.wait(timeout: .now() + 5) == .success } + static func presentationRequests() throws { + let managerURL = URL( + fileURLWithPath: "/Applications/Selected Xcode.app/Contents/Applications/DeviceHub.app") + let deviceID = "target & other=invalid" + let hub = try SimulatorPresenter.request( + deviceID: deviceID, manager: ("Device Hub", managerURL)) + try require( + hub.applicationURL == managerURL && hub.arguments.isEmpty && hub.urls.count == 1, + "Open Device Hub from the selected Xcode.") + let url = URLComponents(url: hub.urls[0], resolvingAgainstBaseURL: false)! + try require( + url.scheme == "devices" && url.host == "device" && url.path == "/open", + "Use the device-opening route.") + try require( + url.queryItems == [URLQueryItem(name: "id", value: deviceID)], + "Encode the exact target as one query parameter.") + let simulatorURL = URL( + fileURLWithPath: + "/Applications/Selected Xcode.app/Contents/Developer/Applications/Simulator.app") + let legacy = try SimulatorPresenter.request( + deviceID: deviceID, manager: ("Simulator", simulatorURL)) + try require( + legacy.applicationURL == simulatorURL && legacy.urls.isEmpty + && legacy.arguments == ["-CurrentDeviceUDID", deviceID], + "Pass the target device to legacy Simulator without shell expansion.") + do { + _ = try SimulatorPresenter.request(deviceID: deviceID, manager: nil) + } catch { return } + throw failure("A missing simulator application must be reported.") + } + static func require(_ condition: Bool, _ message: String) throws { if !condition { throw failure(message) } }