Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions assets/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,22 @@
}
}
},
"Install anyway": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Install anyway"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "仍要安装"
}
}
}
},
"Installed": {
"localizations": {
"en": {
Expand Down
14 changes: 11 additions & 3 deletions docs/previews.md
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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.
21 changes: 17 additions & 4 deletions src/AppInstallButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
}
Expand Down
24 changes: 19 additions & 5 deletions src/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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))
Expand Down
17 changes: 14 additions & 3 deletions src/PreviewScenario.swift
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -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
Expand All @@ -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
35 changes: 33 additions & 2 deletions tests/PreviewScenarioTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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,
])
Expand Down Expand Up @@ -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)
Expand All @@ -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) }
}
Expand Down