From e857d2f21cd6c28d5b3c883f01dff6e283293d47 Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Fri, 6 Nov 2026 00:45:46 -0500 Subject: [PATCH] Preview incompatible builds and clarify installation overrides --- assets/Localizable.xcstrings | 16 +++++++++++++++ docs/previews.md | 14 ++++++++++--- src/AppInstallButton.swift | 21 +++++++++++++++---- src/ContentView.swift | 24 +++++++++++++++++----- src/PreviewScenario.swift | 17 +++++++++++++--- tests/PreviewScenarioTests.swift | 35 ++++++++++++++++++++++++++++++-- 6 files changed, 110 insertions(+), 17 deletions(-) diff --git a/assets/Localizable.xcstrings b/assets/Localizable.xcstrings index 1eeb33f..a73db7e 100644 --- a/assets/Localizable.xcstrings +++ b/assets/Localizable.xcstrings @@ -849,6 +849,22 @@ } } }, + "Install anyway": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install anyway" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仍要安装" + } + } + } + }, "Installed": { "localizations": { "en": { diff --git a/docs/previews.md b/docs/previews.md index 999f16b..85c6d58 100644 --- a/docs/previews.md +++ b/docs/previews.md @@ -1,6 +1,6 @@ -# Setup screen previews +# Debug previews -Debug builds accept `--preview` to show setup screens on a machine with a working Xcode installation. Preview scenarios reuse the application’s views and actions, and refreshes keep showing the selected scenario. +Debug builds accept `--preview` to show setup and compatibility screens on a machine with a working Xcode installation. Preview scenarios reuse the application’s views, and refreshes keep showing the selected scenario. Build the Debug application: @@ -21,10 +21,18 @@ Preview Xcode without available iOS simulators: open -n build/debug/SimStore.app --args --preview no-simulators ``` +Preview incompatible builds: + +```sh +open -n build/debug/SimStore.app --args --preview incompatible +``` + +The incompatible scenario substitutes a running iPhone 8 with iOS 12.0 and an empty installed-app list. It loads the real catalog, so cards show the declared system requirements and details show the compatibility warning for builds requiring a newer OS. No iOS 12 runtime is needed. Device actions and app downloads are unsupported in this scenario; they cannot install, launch, or remove an app on a real simulator. + Start a separate instance with real discovery: ```sh open -n build/debug/SimStore.app ``` -The second scenario uses the selected Xcode installation and substitutes an empty device inventory. Buttons that open the App Store, Xcode, or Device Hub still open the real applications. Each command starts a separate instance, so previews can be viewed side by side. Release builds exclude the preview logic. +The no-simulators and incompatible scenarios use the selected Xcode installation and substitute device inventories. Links and buttons that open the App Store, Xcode, or Device Hub still open the real applications. Each command starts a separate instance, so previews can be viewed side by side. Release builds exclude the preview logic. diff --git a/src/AppInstallButton.swift b/src/AppInstallButton.swift index 93ac877..62f477f 100644 --- a/src/AppInstallButton.swift +++ b/src/AppInstallButton.swift @@ -52,9 +52,22 @@ struct AppInstallButton: View { return allowsIncompatible || version.isCompatible(with: device.runtime) } + private var requiresCompatibilityOverride: Bool { + allowsIncompatible && idleAction != .open && !version.isCompatible(with: device.runtime) + } + + private var isPrimary: Bool { phase == nil && !requiresCompatibilityOverride } + + private func title(for action: AppInstallationAction) -> String { + if action == idleAction, requiresCompatibilityOverride { + return String(localized: "Install anyway") + } + return action.title + } + var body: some View { Group { - if phase == nil { + if isPrimary { button.buttonStyle(.borderedProminent) } else { button.buttonStyle(.bordered) @@ -100,13 +113,13 @@ struct AppInstallButton: View { } } label: { StableSizeView(action, states: AppInstallationAction.allCases) { value in - Text(verbatim: value.title) + Text(verbatim: title(for: value)) } .lineLimit(1) } .disabled(!enabled) - .keyboardShortcut(isDefaultAction && phase == nil ? .defaultAction : nil) - .accessibilityLabel(Text(verbatim: "\(action.title) \(app.name)")) + .keyboardShortcut(isDefaultAction && isPrimary ? .defaultAction : nil) + .accessibilityLabel(Text(verbatim: "\(title(for: action)) \(app.name)")) .accessibilityValue(Text(verbatim: phase?.summary(totalBytes: version.size) ?? "")) .help(Text(verbatim: isInstalled == true ? device.name : version.minimumOSLabel)) } diff --git a/src/ContentView.swift b/src/ContentView.swift index 4418d3b..ed8c16d 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -16,11 +16,6 @@ struct ContentView: View { init( discover: @escaping @MainActor () async throws -> SimulatorDiscovery = { - #if DEBUG - if let scenario = try PreviewScenario.parse(CommandLine.arguments) { - return try await scenario.discover(installedXcodeURL: ContentView.installedXcodeURL) - } - #endif return try await SimulatorDiscovery.scan(installedXcodeURL: ContentView.installedXcodeURL) }, boot: @escaping @Sendable (String) async throws -> Void = { @@ -34,6 +29,25 @@ struct ContentView: View { try await SimulatorApplications.installedApps(on: $0) } ) { + #if DEBUG + let preview = Result { try PreviewScenario.parse(CommandLine.arguments) } + let liveDiscover = discover + let discover: @MainActor () async throws -> SimulatorDiscovery = { + if let scenario = try preview.get() { + return try await scenario.discover(installedXcodeURL: Self.installedXcodeURL) + } + return try await liveDiscover() + } + var installer = installer + var boot = boot + var loadInstalledApps = loadInstalledApps + if case .success(.some(.incompatible)) = preview { + installer.download = { _, _, _ in try PreviewScenario.rejectDeviceAction() } + installer.command = { _, _ in try PreviewScenario.rejectDeviceAction() } + boot = { _ in try PreviewScenario.rejectDeviceAction() } + loadInstalledApps = { _ in [] } + } + #endif let simulators = SimulatorStore(discover: discover, boot: boot) _simulators = State(initialValue: simulators) _catalog = State(initialValue: CatalogStore(client: catalogClient)) diff --git a/src/PreviewScenario.swift b/src/PreviewScenario.swift index 4350eb4..4cc9259 100644 --- a/src/PreviewScenario.swift +++ b/src/PreviewScenario.swift @@ -1,9 +1,10 @@ #if DEBUG import Foundation - enum PreviewScenario: String, Sendable { + enum PreviewScenario: String, CaseIterable, Sendable { case noXcode = "no-xcode" case noSimulators = "no-simulators" + case incompatible static func parse(_ arguments: [String]) throws -> Self? { guard let index = arguments.firstIndex(of: "--preview") else { return nil } @@ -13,7 +14,8 @@ throw NSError( domain: "PreviewScenario", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Use --preview no-xcode or --preview no-simulators." + NSLocalizedDescriptionKey: + "Use --preview with one of: \(Self.allCases.map(\.rawValue).joined(separator: ", "))." ]) } return scenario @@ -26,17 +28,26 @@ switch self { case .noXcode: throw SimulatorDiscoveryError.xcodeNotInstalled - case .noSimulators: + case .noSimulators, .incompatible: return try await SimulatorDiscovery.scan(installedXcodeURL: installedXcodeURL) { executable, arguments in if executable == Command.xcrunExecutable, arguments.starts(with: [Command.simctlTool, "list", "devices"]) { + if self == .incompatible { + return Data( + #"{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-12-0":[{"udid":"00000000-0000-0000-0000-000000000001","name":"iPhone 8","state":"Booted","isAvailable":true}]}}"# + .utf8) + } return Data(#"{"devices": {}}"#.utf8) } return try command(executable, arguments) } } } + + static func rejectDeviceAction() throws -> Never { + throw CocoaError(.featureUnsupported) + } } #endif diff --git a/tests/PreviewScenarioTests.swift b/tests/PreviewScenarioTests.swift index 493c5f9..3276449 100644 --- a/tests/PreviewScenarioTests.swift +++ b/tests/PreviewScenarioTests.swift @@ -7,7 +7,10 @@ struct PreviewScenarioTests { try arguments() try await missingXcode() try await emptyInventory() - print("PASS: Debug preview arguments, forced missing Xcode, and empty simulator inventory.") + try blockedDeviceActions() + print( + "PASS: Debug preview arguments, missing Xcode, empty inventory, and an isolated incompatible device." + ) } catch { FileHandle.standardError.write( Data("Preview scenario test failed: \(error.localizedDescription)\n".utf8)) @@ -20,7 +23,7 @@ struct PreviewScenarioTests { try require( try PreviewScenario.parse(args) == nil, "Normal launches must not enable previews.") } - for mode in ["no-xcode", "no-simulators"] { + for mode in PreviewScenario.allCases.map(\.rawValue) { let parsed = try PreviewScenario.parse([ "SimStore", "-AppleLanguages", "(zh-Hans)", "--preview", mode, ]) @@ -84,6 +87,24 @@ struct PreviewScenarioTests { snapshot.deviceManager?.url.path == manager.path, "The empty-inventory preview must preserve Device Hub discovery.") + let incompatible = try await PreviewScenario.incompatible.discover(installedXcodeURL: nil) { + executable, args in + try require( + executable == "/usr/bin/xcode-select" && args == ["--print-path"], + "The incompatible preview must not query real simulator devices.") + return Data("\(developer.path)\n".utf8) + } + try require( + incompatible.devices == [ + SimulatorDevice( + id: "00000000-0000-0000-0000-000000000001", name: "iPhone 8", runtime: "12.0", + state: "Booted") + ], + "The incompatible preview must provide a running old device without a local runtime.") + try require( + incompatible.deviceManager?.url.path == manager.path, + "The incompatible preview must retain the selected Xcode's device manager.") + do { _ = try await PreviewScenario.noSimulators.discover(installedXcodeURL: nil) { _, _ in Data("/Library/Developer/CommandLineTools\n".utf8) @@ -107,6 +128,16 @@ struct PreviewScenarioTests { } } + static func blockedDeviceActions() throws { + do { + try PreviewScenario.rejectDeviceAction() + } catch let error as CocoaError { + try require( + error.code == .featureUnsupported, "Device actions in the preview must fail as unsupported." + ) + } + } + static func require(_ condition: Bool, _ message: String) throws { if !condition { throw failure(message) } }