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
18 changes: 18 additions & 0 deletions engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,24 @@ struct ODHubViewer: ParsableCommand {
}
}
},
newSimulator: {
let simctl = SimctlService()
NewSimulatorPanel.show(actions: NewSimulatorActions(
deviceTypes: { (try? simctl.listDeviceTypes()) ?? [] },
runtimeSupport: { (try? simctl.listRuntimeSupport()) ?? [] },
existingNames: { ((try? adapter.devices()) ?? []).map(\.name) },
create: { name, type, runtime in
try simctl.createDevice(
name: name,
deviceType: type.identifier,
runtime: runtime.identifier
)
},
// A simulator created and then not shown would be a puzzle, so it opens,
// which boots it the same way the chooser does.
created: { udid in bootThenShow(udid) }
), settings: settings)
},
setOrientation: { orientation in
for udid in manager.openUDIDs {
guard let controller = manager.controller(for: udid) else { continue }
Expand Down
21 changes: 21 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/Simctl/SimctlModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@ enum SimctlModels {
let devices: [String: [SimctlDevice]]
}

struct DeviceTypeList: Decodable {
let devicetypes: [SimctlDeviceType]
}

/// The same list as `RuntimeList` with the supported device types kept, which the plain runtime
/// model drops.
struct RuntimeSupportList: Decodable {
struct Runtime: Decodable {
struct DeviceType: Decodable {
let identifier: String
}
let identifier: String
let name: String
let version: String
let buildversion: String
let isAvailable: Bool
let supportedDeviceTypes: [DeviceType]?
}
let runtimes: [Runtime]
}

struct RuntimeList: Decodable {
let runtimes: [SimctlRuntime]
}
Expand Down
50 changes: 50 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/Simctl/SimctlService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,53 @@ extension SimctlService {
}
}
}

/// Creating a simulator, and the lists a person picks from to do it.
extension SimctlService {
static func listDeviceTypesArguments() -> [String] {
["simctl", "list", "devicetypes", "-j"]
}

static func createArguments(name: String, deviceType: String, runtime: String) -> [String] {
["simctl", "create", name, deviceType, runtime]
}

public func listDeviceTypes() throws -> [SimctlDeviceType] {
let data = try run(Self.listDeviceTypesArguments())
return try JSONDecoder().decode(SimctlModels.DeviceTypeList.self, from: data).devicetypes
}

/// Which device types each runtime can run, which `simctl` only reports here rather than on the
/// device types themselves.
public func listRuntimeSupport() throws -> [SimctlRuntimeSupport] {
let data = try run(Self.listRuntimesArguments())
let decoded = try JSONDecoder().decode(SimctlModels.RuntimeSupportList.self, from: data)
return decoded.runtimes.map { runtime in
SimctlRuntimeSupport(
runtime: SimctlRuntime(
identifier: runtime.identifier,
name: runtime.name,
version: runtime.version,
buildversion: runtime.buildversion,
isAvailable: runtime.isAvailable
),
deviceTypeIdentifiers: Set((runtime.supportedDeviceTypes ?? []).map(\.identifier))
)
}
}

/// Returns the new device's UDID, which is all `simctl create` prints.
@discardableResult
public func createDevice(name: String, deviceType: String, runtime: String) throws -> String {
let arguments = Self.createArguments(name: name, deviceType: deviceType, runtime: runtime)
let result = try ProcessRunner.run("/usr/bin/xcrun", arguments)
guard result.status == 0 else {
throw EngineError.simctl(
args: Array(arguments.dropFirst()),
code: result.status,
stderr: result.standardError
)
}
return result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
72 changes: 72 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/Simctl/SimulatorCreation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation

/// A device type a simulator can be created as.
public struct SimctlDeviceType: Sendable, Hashable, Codable {
public let identifier: String
public let name: String

public init(identifier: String, name: String) {
self.identifier = identifier
self.name = name
}
}

/// What a runtime can run. `simctl` reports this per runtime, and it matters: a newly released
/// runtime can support a single device type while an older one supports sixty.
public struct SimctlRuntimeSupport: Sendable, Hashable {
public let runtime: SimctlRuntime
public let deviceTypeIdentifiers: Set<String>

public init(runtime: SimctlRuntime, deviceTypeIdentifiers: Set<String>) {
self.runtime = runtime
self.deviceTypeIdentifiers = deviceTypeIdentifiers
}
}

/// Which device types and runtimes go together, and what to call a new simulator.
///
/// Pure, because the pairing is the part that is easy to get wrong and impossible to notice: an
/// unsupported pair is refused by `simctl` with a message about the runtime, long after the person
/// chose the device.
public enum SimulatorCreation {
/// iOS only. The other platforms are out of scope for this app, and offering a watchOS device
/// that it cannot then show would be a promise it does not keep.
public static func isSupported(_ type: SimctlDeviceType) -> Bool {
type.identifier.contains(".iPhone-") || type.identifier.contains(".iPad")
}

public static func runtimes(
for type: SimctlDeviceType,
in support: [SimctlRuntimeSupport]
) -> [SimctlRuntime] {
support
.filter { $0.runtime.isAvailable && $0.deviceTypeIdentifiers.contains(type.identifier) }
.map(\.runtime)
.sorted { $0.version.compare($1.version, options: .numeric) == .orderedDescending }
}

public static func deviceTypes(
in support: [SimctlRuntimeSupport],
from types: [SimctlDeviceType]
) -> [SimctlDeviceType] {
let runnable = support
.filter(\.runtime.isAvailable)
.reduce(into: Set<String>()) { $0.formUnion($1.deviceTypeIdentifiers) }
return types
.filter { isSupported($0) && runnable.contains($0.identifier) }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}

/// The name a person would have typed, so the field is never empty. A name already taken gets a
/// number, because `simctl` allows duplicates and two identical rows in the chooser help nobody.
public static func suggestedName(
for type: SimctlDeviceType,
existing: [String]
) -> String {
guard existing.contains(type.name) else { return type.name }
for suffix in 2...99 where !existing.contains("\(type.name) \(suffix)") {
return "\(type.name) \(suffix)"
}
return type.name
}
}
196 changes: 196 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/NewSimulatorPanel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import AppKit
import OpenDeviceHubEngine
import SwiftUI

/// What the panel needs to do its job, passed in so it never reaches for `simctl` itself.
@MainActor
public struct NewSimulatorActions {
public var deviceTypes: () -> [SimctlDeviceType]
public var runtimeSupport: () -> [SimctlRuntimeSupport]
public var existingNames: () -> [String]
/// Returns the new device's UDID, or throws with something worth showing.
public var create: (String, SimctlDeviceType, SimctlRuntime) throws -> String
public var created: (String) -> Void

public init(
deviceTypes: @escaping () -> [SimctlDeviceType],
runtimeSupport: @escaping () -> [SimctlRuntimeSupport],
existingNames: @escaping () -> [String],
create: @escaping (String, SimctlDeviceType, SimctlRuntime) throws -> String,
created: @escaping (String) -> Void
) {
self.deviceTypes = deviceTypes
self.runtimeSupport = runtimeSupport
self.existingNames = existingNames
self.create = create
self.created = created
}
}

@MainActor
public enum NewSimulatorPanel {
private static var controller: NewSimulatorWindowController?

public static func show(actions: NewSimulatorActions, settings: ViewerSettings = ViewerSettings()) {
// Rebuilt each time rather than reused: the runtimes and device types on the machine can
// change between openings, and a stale list would offer something that is no longer there.
let controller = NewSimulatorWindowController(actions: actions, settings: settings)
Self.controller = controller
NSApplication.shared.activate(ignoringOtherApps: true)
controller.showWindow(nil)
controller.window?.makeKeyAndOrderFront(nil)
}

static func dismiss() {
controller?.close()
controller = nil
}
}

@MainActor
final class NewSimulatorWindowController: NSWindowController {
init(actions: NewSimulatorActions, settings: ViewerSettings) {
let view = NewSimulatorView(actions: actions, settings: settings)
let window = NSWindow(contentViewController: NSHostingController(rootView: view))
window.title = "New Simulator"
window.styleMask = [.titled, .closable]
window.isReleasedWhenClosed = false
super.init(window: window)
window.center()
}

required init?(coder: NSCoder) { fatalError("init(coder:) is unsupported") }
}

@MainActor
private struct NewSimulatorView: View {
private let actions: NewSimulatorActions
private let settings: ViewerSettings
private let types: [SimctlDeviceType]
private let support: [SimctlRuntimeSupport]

@State private var name: String
@State private var type: SimctlDeviceType?
@State private var runtime: SimctlRuntime?
@State private var failure: String?
/// True once the name has been edited, so the suggestion stops following the device type.
@State private var nameIsMine = false

init(actions: NewSimulatorActions, settings: ViewerSettings) {
self.actions = actions
self.settings = settings
let support = actions.runtimeSupport()
let types = SimulatorCreation.deviceTypes(in: support, from: actions.deviceTypes())
self.support = support
self.types = types
let first = types.first
_type = State(initialValue: first)
_runtime = State(initialValue: first.flatMap {
SimulatorCreation.runtimes(for: $0, in: support).first
})
_name = State(initialValue: first.map {
SimulatorCreation.suggestedName(for: $0, existing: actions.existingNames())
} ?? "")
}

private var runtimes: [SimctlRuntime] {
type.map { SimulatorCreation.runtimes(for: $0, in: support) } ?? []
}

private var canCreate: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty && type != nil && runtime != nil
}

var body: some View {
Form {
Section {
TextField("Name", text: $name)
.onChange(of: name) { _, _ in nameIsMine = true }

Picker("Device Type", selection: $type) {
ForEach(types, id: \.identifier) { candidate in
Text(candidate.name).tag(Optional(candidate))
}
}
.onChange(of: type) { _, _ in deviceTypeChanged() }

Picker("OS Version", selection: $runtime) {
ForEach(runtimes, id: \.identifier) { candidate in
Text(candidate.name).tag(Optional(candidate))
}
}
.disabled(runtimes.isEmpty)
} footer: {
if let failure {
Text(failure).font(.footnote).foregroundStyle(Color.red)
} else if runtimes.count == 1, let only = runtimes.first {
// Worth saying: one runtime is normal for a device only just added.
Text("\(only.name) is the only version that runs this device.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
.safeAreaInset(edge: .bottom) {
HStack {
Button("Cancel") { NewSimulatorPanel.dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Previous") { fillFromPrevious() }
.disabled(settings.lastCreatedSimulator == nil)
.help("Fill this in with the last simulator you created.")
Button("Create") { create() }
.keyboardShortcut(.defaultAction)
.disabled(!canCreate)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
.frame(width: 460)
.fixedSize(horizontal: false, vertical: true)
}

private func deviceTypeChanged() {
// The chosen runtime may not run the new device, so it follows rather than going stale.
if let runtime, !runtimes.contains(runtime) {
self.runtime = runtimes.first
} else if runtime == nil {
runtime = runtimes.first
}
if !nameIsMine, let type {
name = SimulatorCreation.suggestedName(for: type, existing: actions.existingNames())
// Assigning the field fires onChange, which would otherwise look like the person typing.
nameIsMine = false
}
}

private func fillFromPrevious() {
guard let previous = settings.lastCreatedSimulator else { return }
if let match = types.first(where: { $0.identifier == previous.deviceType }) {
type = match
runtime = SimulatorCreation.runtimes(for: match, in: support)
.first { $0.identifier == previous.runtime }
?? SimulatorCreation.runtimes(for: match, in: support).first
}
name = previous.name
nameIsMine = true
}

private func create() {
guard let type, let runtime else { return }
let wanted = name.trimmingCharacters(in: .whitespaces)
do {
let udid = try actions.create(wanted, type, runtime)
settings.lastCreatedSimulator = LastCreatedSimulator(
name: wanted,
deviceType: type.identifier,
runtime: runtime.identifier
)
NewSimulatorPanel.dismiss()
actions.created(udid)
} catch {
failure = error.localizedDescription
}
}
}
Loading
Loading