From f97a3e4f9e80cb7ce5480e829a440f2507c3db49 Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Wed, 4 Nov 2026 11:21:33 -0500 Subject: [PATCH 1/5] Add simulator discovery and setup interface --- src/CMakeLists.txt | 1 + src/ContentView.swift | 436 +++++++++++++++++++++++++++- src/SimStoreApp.swift | 2 +- src/SimulatorDiscovery.swift | 127 ++++++++ tests/CMakeLists.txt | 4 + tests/SimulatorDiscoveryTests.swift | 180 ++++++++++++ 6 files changed, 735 insertions(+), 15 deletions(-) create mode 100644 src/SimulatorDiscovery.swift create mode 100644 tests/SimulatorDiscoveryTests.swift diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ef21635..e97da92 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,7 @@ add_executable(SimStore MACOSX_BUNDLE AppDelegate.swift SimStoreApp.swift + SimulatorDiscovery.swift ContentView.swift ) diff --git a/src/ContentView.swift b/src/ContentView.swift index d3d062d..8dfbd25 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -3,26 +3,434 @@ import SwiftUI @MainActor struct ContentView: View { - @State private var cannotOpenRepository = false + private let discover: @MainActor () async throws -> SimulatorDiscovery + @State private var result: Result? + @State private var isChecking = false + @State private var launchError: String? - private let repositoryURL = URL(string: "https://github.com/eagleoflqj/simstore-builds")! + init( + discover: @escaping @MainActor () async throws -> SimulatorDiscovery = { + try await SimulatorDiscovery.scan( + installedXcodeURL: NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "com.apple.dt.Xcode")) + } + ) { + self.discover = discover + } + + private var needsXcode: Bool { + guard case .failure(let error) = result else { return false } + return (error as? SimulatorDiscoveryError) == .xcodeNotInstalled + } + + private var discovery: SimulatorDiscovery? { + guard case .success(let value) = result else { return nil } + return value + } + + private var deviceManager: (name: String, url: URL)? { + discovery?.deviceManager + } var body: some View { - ContentUnavailableView { - Label("SimStore", systemImage: "square.stack.3d.up.fill") - } description: { - Text("Open-source apps for iOS Simulator.") - } actions: { - Button("View App Repository", systemImage: "arrow.up.right") { - cannotOpenRepository = !NSWorkspace.shared.open(repositoryURL) + NavigationSplitView { + GeometryReader { geometry in + sidebar + .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) + } + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 260) + } detail: { + GeometryReader { geometry in + ZStack(alignment: .topLeading) { + discoveryContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + + VStack(alignment: .leading, spacing: 7) { + Text("Discover") + .font(.system(size: 30, weight: .bold)) + .tracking(-0.8) + Text("Open-source favorites. Built for your simulator.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .padding(32) + } + .frame(width: geometry.size.width, height: geometry.size.height) + .background(Color(nsColor: .textBackgroundColor)) + } + .toolbar { + ToolbarItem(placement: .navigation) { + Label("No simulator selected", systemImage: "iphone") + .labelStyle(.titleAndIcon) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.horizontal, 8) + .fixedSize() + } + ToolbarItem(placement: .primaryAction) { + Button { + Task { await refresh() } + } label: { + Label("Check for Simulators", systemImage: "arrow.clockwise") + } + .disabled(isChecking) + .keyboardShortcut("r", modifiers: .command) + .help("Check for iOS simulators") + } } - .controlSize(.large) } - .frame(minWidth: 640, minHeight: 420) - .alert("Couldn’t Open Repository", isPresented: $cannotOpenRepository) { - Button("OK", role: .cancel) {} + .navigationSplitViewStyle(.balanced) + .frame(minWidth: 900, minHeight: 600) + .task { await refresh() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) + { _ in + Task { await refresh() } + } + .alert( + "Couldn’t Open Application", + isPresented: Binding( + get: { launchError != nil }, + set: { if !$0 { launchError = nil } } + ) + ) { + Button("OK", role: .cancel) { launchError = nil } } message: { - Text("Open this address in your web browser: \(repositoryURL.absoluteString)") + Text(launchError ?? "") + } + } + + private var sidebar: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .frame(width: 37, height: 37) + Text("SimStore") + .font(.system(size: 22, weight: .bold)) + .tracking(-0.7) + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 27) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 5) { + Label("Discover", systemImage: "safari") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 11) + .background(Color.accentColor.opacity(0.10), in: RoundedRectangle(cornerRadius: 8)) + .accessibilityAddTraits(.isSelected) + sidebarPlaceholder("Installed", systemImage: "square.grid.2x2") + sidebarPlaceholder("Updates", systemImage: "arrow.triangle.2.circlepath") + } + .padding(.horizontal, 12) + .fixedSize(horizontal: false, vertical: true) + + Text("SIMULATORS") + .font(.system(size: 10, weight: .semibold)) + .tracking(1) + .foregroundStyle(.secondary) + .padding(.horizontal, 24) + .padding(.top, 32) + .padding(.bottom, 15) + .fixedSize(horizontal: false, vertical: true) + + if let discovery, !discovery.devices.isEmpty { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(discovery.devices) { device in + Label { + VStack(alignment: .leading, spacing: 4) { + Text(device.name).font(.system(size: 12, weight: .medium)) + Text("iOS \(device.runtime) · \(device.state)") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } icon: { + Image(systemName: device.name.contains("iPad") ? "ipad.landscape" : "iphone") + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 24) + .padding(.vertical, 4) + } + .frame(minHeight: 0) + } else { + VStack(alignment: .leading, spacing: 7) { + Text( + isChecking + ? "Checking for devices…" + : needsXcode + ? "Xcode required" + : discovery == nil ? "No devices to display" : "No simulators yet" + ) + .font(.system(size: 12, weight: .medium)) + Text( + needsXcode + ? "Install Xcode to add iPhone and iPad simulators." + : "Your iPhone and iPad simulators will appear here." + ) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineSpacing(3) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal, 16) + } + + Spacer(minLength: 24) + + Button { + if !NSWorkspace.shared.open(URL(string: "https://github.com/eagleoflqj/simstore-builds")!) { + launchError = "Open https://github.com/eagleoflqj/simstore-builds in your web browser." + } + } label: { + Label("App Repository", systemImage: "arrow.up.right.square") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(24) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func sidebarPlaceholder(_ title: String, systemImage: String) -> some View { + Label(title, systemImage: systemImage) + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 11) + } + + @ViewBuilder + private var discoveryContent: some View { + switch result { + case .none: + VStack(spacing: 15) { + ProgressView().controlSize(.small) + Text("Looking for your simulators…") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + case .some(.success(let snapshot)) where snapshot.devices.isEmpty: + emptyState + case .some(.success(let snapshot)): + VStack(spacing: 14) { + Image(systemName: "iphone.and.arrow.forward") + .font(.system(size: 48, weight: .light)) + .foregroundStyle(Color.accentColor) + Text("Your simulators are ready") + .font(.system(size: 24, weight: .semibold)) + Text( + "Found \(snapshot.devices.count) available iOS \(snapshot.devices.count == 1 ? "simulator" : "simulators")." + ) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + case .some(.failure) where needsXcode: + missingXcodeState + case .some(.failure(let error)): + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 44, weight: .light)) + .foregroundStyle(.orange) + Text("Couldn’t check for simulators") + .font(.system(size: 24, weight: .semibold)) + Text( + "Make sure Xcode is installed and its command-line tools are selected, then try again." + ) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 390) + HStack(spacing: 10) { + Button("Open Xcode", systemImage: "hammer") { openXcode() } + Button(isChecking ? "Checking…" : "Try Again", systemImage: "arrow.clockwise") { + Task { await refresh() } + } + .disabled(isChecking) + } + .controlSize(.large) + DisclosureGroup("Details") { + Text(error.localizedDescription) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + } + .font(.system(size: 11)) + .frame(maxWidth: 390) + } + .padding(30) + } + } + + private var missingXcodeState: some View { + VStack(spacing: 0) { + Image(systemName: "hammer") + .font(.system(size: 58, weight: .light)) + .foregroundStyle(Color.accentColor) + .frame(width: 130, height: 130) + .background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 30)) + .padding(.bottom, 30) + Text("Start with Xcode.") + .font(.system(size: 25, weight: .semibold)) + .tracking(-0.6) + Text( + "Xcode provides the tools SimStore needs to run iOS apps.\nInstall it from the App Store, then open it to finish setup." + ) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(5) + .padding(.top, 12) + HStack(spacing: 10) { + Button("Open App Store", systemImage: "bag") { + if !NSWorkspace.shared.open(URL(string: "macappstore://apps.apple.com/app/id497799835")!) + { + launchError = "Open the App Store and search for Xcode." + } + } + .buttonStyle(.borderedProminent) + Button(isChecking ? "Checking…" : "Check Again", systemImage: "arrow.clockwise") { + Task { await refresh() } + } + .buttonStyle(.bordered) + .disabled(isChecking) + } + .controlSize(.large) + .padding(.top, 25) + } + .padding(.horizontal, 30) + } + + private var emptyState: some View { + VStack(spacing: 0) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [Color.accentColor.opacity(0.08), Color.accentColor.opacity(0)], + center: .center, startRadius: 35, endRadius: 125) + ) + .frame(width: 250, height: 200) + Image(systemName: "ipad.landscape") + .font(.system(size: 78, weight: .ultraLight)) + .foregroundStyle(Color.secondary.opacity(0.25)) + .rotationEffect(.degrees(-9)) + .offset(x: -35, y: 12) + Image(systemName: "iphone.gen3") + .font(.system(size: 112, weight: .ultraLight)) + .foregroundStyle(Color.accentColor.opacity(0.85)) + .rotationEffect(.degrees(9)) + .offset(x: 36, y: -6) + } + .accessibilityHidden(true) + + Text("A simulator is where it starts.") + .font(.system(size: 25, weight: .semibold)) + .tracking(-0.6) + .padding(.top, 6) + Text( + "Create an iPhone or iPad simulator in \(deviceManager?.name ?? "Xcode").\nThen come back to find your next favorite app." + ) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(5) + .padding(.top, 12) + + HStack(spacing: 10) { + Button( + "Open \(deviceManager?.name ?? "Xcode")", + systemImage: deviceManager?.name == nil ? "hammer" : "iphone.and.arrow.forward" + ) { + if let deviceManager { + openApplication(deviceManager.url, name: deviceManager.name) + } else { + openXcode() + } + } + .buttonStyle(.borderedProminent) + Button(isChecking ? "Checking…" : "Check Again", systemImage: "arrow.clockwise") { + Task { await refresh() } + } + .buttonStyle(.bordered) + .disabled(isChecking) + } + .controlSize(.large) + .padding(.top, 25) + + if deviceManager?.name != nil { + Button { + openXcode() + } label: { + HStack(spacing: 5) { + Text("Open Xcode") + Image(systemName: "arrow.up.right").font(.system(size: 9, weight: .medium)) + } + .font(.system(size: 11)) + } + .buttonStyle(.link) + .padding(.top, 18) + } else { + Text("Device Hub or Simulator couldn’t be found in the selected Xcode installation.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(.top, 18) + } + } + .padding(.horizontal, 30) + } + + private func refresh() async { + guard !isChecking else { return } + isChecking = true + defer { isChecking = false } + do { + let snapshot = try await discover() + try Task.checkCancellation() + result = .success(snapshot) + } catch is CancellationError { + return + } catch { + result = .failure(error) + } + } + + private func openXcode() { + openApplication( + discovery?.xcodeURL + ?? NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode"), + name: "Xcode") + } + + private func openApplication(_ url: URL?, name: String) { + guard let url, FileManager.default.fileExists(atPath: url.path) else { + launchError = + "\(name) couldn’t be opened. Make sure the full Xcode application is installed and selected." + return + } + NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration()) { + _, error in + if let error { + Task { @MainActor in + launchError = "\(name) couldn’t be opened. \(error.localizedDescription)" + } + } } } } diff --git a/src/SimStoreApp.swift b/src/SimStoreApp.swift index 1a7a1de..8886775 100644 --- a/src/SimStoreApp.swift +++ b/src/SimStoreApp.swift @@ -8,6 +8,6 @@ struct SimStoreApp: App { WindowGroup("SimStore") { ContentView() } - .defaultSize(width: 800, height: 540) + .defaultSize(width: 1180, height: 780) } } diff --git a/src/SimulatorDiscovery.swift b/src/SimulatorDiscovery.swift new file mode 100644 index 0000000..6c71383 --- /dev/null +++ b/src/SimulatorDiscovery.swift @@ -0,0 +1,127 @@ +import Foundation + +enum SimulatorDiscoveryError: LocalizedError, Equatable { + case xcodeNotInstalled + + var errorDescription: String? { "Xcode is not installed." } +} + +struct SimulatorDevice: Identifiable, Equatable, Sendable { + let id: String + let name: String + let runtime: String + let state: String +} + +struct SimulatorDiscovery: Sendable { + let developerDirectory: URL + let devices: [SimulatorDevice] + + var deviceManager: (name: String, url: URL)? { + let applications = [ + ( + name: "Device Hub", + url: xcodeURL.appendingPathComponent("Contents/Applications/DeviceHub.app") + ), + ( + name: "Simulator", + url: developerDirectory.appendingPathComponent("Applications/Simulator.app") + ), + ] + return applications.first { FileManager.default.fileExists(atPath: $0.url.path) } + } + + var xcodeURL: URL { + developerDirectory.deletingLastPathComponent().deletingLastPathComponent() + } + + static func scan( + installedXcodeURL: URL?, + command: @escaping @Sendable (String, [String]) throws -> Data = commandOutput + ) async throws -> SimulatorDiscovery { + try await Task.detached(priority: .userInitiated) { + let directoryData: Data + do { + directoryData = try command("/usr/bin/xcode-select", ["--print-path"]) + } catch { + if isXcodeApplication(installedXcodeURL) { throw error } + throw SimulatorDiscoveryError.xcodeNotInstalled + } + let directory = String(decoding: directoryData, as: UTF8.self).trimmingCharacters( + in: .whitespacesAndNewlines) + guard directory.hasPrefix("/") else { + throw NSError( + domain: "SimulatorDiscovery", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Xcode did not provide a valid developer directory." + ]) + } + let developerDirectory = URL(fileURLWithPath: directory) + let selectedXcode = developerDirectory.deletingLastPathComponent().deletingLastPathComponent() + guard isXcodeApplication(selectedXcode) || isXcodeApplication(installedXcodeURL) else { + throw SimulatorDiscoveryError.xcodeNotInstalled + } + let deviceData = try command( + "/usr/bin/xcrun", ["simctl", "list", "devices", "available", "--json"]) + return SimulatorDiscovery( + developerDirectory: developerDirectory, devices: try decodeDevices(deviceData)) + }.value + } + + private static func isXcodeApplication(_ url: URL?) -> Bool { + guard let url, FileManager.default.fileExists(atPath: url.path) else { return false } + return Bundle(url: url)?.bundleIdentifier == "com.apple.dt.Xcode" + } + + static func decodeDevices(_ data: Data) throws -> [SimulatorDevice] { + struct Response: Decodable { + struct Device: Decodable { + let udid: String + let name: String + let state: String + let isAvailable: Bool + } + let devices: [String: [Device]] + } + + let response = try JSONDecoder().decode(Response.self, from: data) + let prefix = "com.apple.CoreSimulator.SimRuntime.iOS-" + return response.devices.filter { $0.key.hasPrefix(prefix) }.flatMap { runtime, devices in + devices.filter(\.isAvailable).map { device in + SimulatorDevice( + id: device.udid, + name: device.name, + runtime: String(runtime.dropFirst(prefix.count)).replacingOccurrences(of: "-", with: "."), + state: device.state + ) + } + }.sorted { + if $0.name == $1.name { return $0.id < $1.id } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } + + static func commandOutput(_ executable: String, arguments: [String]) throws -> Data { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardOutput = pipe + process.standardError = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + let diagnostic = String(decoding: data, as: UTF8.self).trimmingCharacters( + in: .whitespacesAndNewlines) + throw NSError( + domain: "SimulatorDiscovery", code: Int(process.terminationStatus), + userInfo: [ + NSLocalizedDescriptionKey: diagnostic.isEmpty + ? "\(process.executableURL!.lastPathComponent) exited with status \(process.terminationStatus)." + : diagnostic + ]) + } + return data + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 87cc31a..de04bf6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,6 +9,10 @@ add_executable(SimStoreLifecycleTest AppLifecycleTest.swift "${PROJECT_SOURCE_DI add_test(NAME app-window-close COMMAND SimStoreLifecycleTest) set_tests_properties(app-window-close PROPERTIES TIMEOUT 10 RUN_SERIAL TRUE) +add_executable(SimStoreDiscoveryTest SimulatorDiscoveryTests.swift "${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift") +add_test(NAME simulator-discovery COMMAND SimStoreDiscoveryTest) +set_tests_properties(simulator-discovery PROPERTIES TIMEOUT 15) + execute_process(COMMAND xcrun --sdk "${CMAKE_OSX_SYSROOT}" --show-sdk-version OUTPUT_VARIABLE SDK_VERSION OUTPUT_STRIP_TRAILING_WHITESPACE diff --git a/tests/SimulatorDiscoveryTests.swift b/tests/SimulatorDiscoveryTests.swift new file mode 100644 index 0000000..4403825 --- /dev/null +++ b/tests/SimulatorDiscoveryTests.swift @@ -0,0 +1,180 @@ +import Foundation + +@main +struct SimulatorDiscoveryTests { + static func main() async { + do { + try emptyInventory() + try availableIOSDevices() + try malformedInventory() + try commandFailure() + try deviceManagerLocations() + try await xcodeInstallationStates() + print( + "PASS: simulator inventory, Device Hub / Simulator discovery, missing Xcode, and incomplete Xcode setup." + ) + } catch { + FileHandle.standardError.write( + Data("Simulator discovery test failed: \(error.localizedDescription)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + static func emptyInventory() throws { + let devices = try SimulatorDiscovery.decodeDevices(Data(#"{"devices": {}}"#.utf8)) + try require(devices.isEmpty, "An empty inventory should contain no devices.") + } + + static func availableIOSDevices() throws { + let data = Data( + #""" + {"devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-27-1": [ + {"udid": "PHONE", "name": "iPhone 17 Pro", "state": "Shutdown", "isAvailable": true}, + {"udid": "UNAVAILABLE", "name": "Old iPhone", "state": "Shutdown", "isAvailable": false} + ], + "com.apple.CoreSimulator.SimRuntime.iOS-18-5": [ + {"udid": "TABLET", "name": "iPad Air", "state": "Booted", "isAvailable": true} + ], + "com.apple.CoreSimulator.SimRuntime.tvOS-27-0": [ + {"udid": "TV", "name": "Apple TV", "state": "Booted", "isAvailable": true} + ] + }} + """#.utf8) + let devices = try SimulatorDiscovery.decodeDevices(data) + try require( + Set(devices.map(\.id)) == ["PHONE", "TABLET"], + "Only available iOS devices should be included.") + try require( + devices.first(where: { $0.id == "PHONE" })?.runtime == "27.1", + "The runtime version should come from its runtime identifier.") + try require( + devices.first(where: { $0.id == "PHONE" })?.state == "Shutdown", + "A shut-down simulator must still count as an available device.") + try require( + devices.first(where: { $0.id == "TABLET" })?.state == "Booted", + "The device's power state should be preserved.") + } + + static func malformedInventory() throws { + for response in [ + "not JSON", "{}", #"{"devices": []}"#, + #"{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-27-1":[{"name":"Incomplete"}]}}"#, + ] { + do { + _ = try SimulatorDiscovery.decodeDevices(Data(response.utf8)) + } catch { + continue + } + throw failure("An invalid response must fail instead of being interpreted as no simulators.") + } + } + + static func commandFailure() throws { + do { + _ = try SimulatorDiscovery.commandOutput( + "/bin/sh", arguments: ["-c", "printf 'simulator tools unavailable' >&2; exit 9"]) + } catch { + try require( + error.localizedDescription.contains("simulator tools unavailable"), + "A failed command should preserve its diagnostic.") + return + } + throw failure("A nonzero command exit must fail instead of returning an empty inventory.") + } + + static func deviceManagerLocations() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "SimStore Discovery \(UUID().uuidString)") + let xcode = root.appendingPathComponent("Xcode Beta.app") + let developer = xcode.appendingPathComponent("Contents/Developer") + try FileManager.default.createDirectory(at: developer, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let discovery = SimulatorDiscovery(developerDirectory: developer, devices: []) + try require( + discovery.deviceManager?.name == nil, + "A missing application must not be offered as available.") + + let legacy = developer.appendingPathComponent("Applications/Simulator.app") + try FileManager.default.createDirectory(at: legacy, withIntermediateDirectories: true) + try require( + discovery.deviceManager?.name == "Simulator", + "Older Xcode installations should use Simulator.") + try require( + discovery.deviceManager?.url.path == legacy.path, + "Simulator must belong to the selected Xcode.") + + let modern = xcode.appendingPathComponent("Contents/Applications/DeviceHub.app") + try FileManager.default.createDirectory(at: modern, withIntermediateDirectories: true) + try require( + discovery.deviceManager?.name == "Device Hub", "Device Hub should be preferred when present.") + try require( + discovery.deviceManager?.url.path == modern.path, + "Device Hub must belong to the selected Xcode, including paths with spaces.") + } + + static func xcodeInstallationStates() async throws { + for hasCommandLineTools in [false, true] { + do { + _ = try await SimulatorDiscovery.scan(installedXcodeURL: nil) { _, _ in + if hasCommandLineTools { return Data("/Library/Developer/CommandLineTools\n".utf8) } + throw failure("No developer directory selected") + } + throw failure("Missing Xcode should require installation.") + } catch { + try require( + (error as? SimulatorDiscoveryError) == .xcodeNotInstalled, + "Command Line Tools alone must not be treated as a full Xcode installation.") + } + } + + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "SimStore Xcode \(UUID().uuidString)") + let xcode = root.appendingPathComponent("Xcode Beta.app") + let developer = xcode.appendingPathComponent("Contents/Developer") + try FileManager.default.createDirectory(at: developer, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let info = try PropertyListSerialization.data( + fromPropertyList: [ + "CFBundleIdentifier": "com.apple.dt.Xcode", "CFBundlePackageType": "APPL", + ], format: .xml, options: 0) + try info.write(to: xcode.appendingPathComponent("Contents/Info.plist")) + + do { + _ = try await SimulatorDiscovery.scan(installedXcodeURL: xcode) { executable, _ in + if executable.hasSuffix("xcode-select") { + return Data("/Library/Developer/CommandLineTools\n".utf8) + } + throw failure("Xcode tools not selected") + } + throw failure( + "An unconfigured toolchain must not be interpreted as an empty device inventory.") + } catch { + try require( + error.localizedDescription == "Xcode tools not selected", + "Installed Xcode with unavailable tools should report the setup error, not ask for installation again." + ) + } + + let snapshot = try await SimulatorDiscovery.scan(installedXcodeURL: nil) { executable, _ in + if executable.hasSuffix("xcode-select") { return Data("\(developer.path)\n".utf8) } + return Data(#"{"devices": {}}"#.utf8) + } + try require( + snapshot.devices.isEmpty, + "A valid selected Xcode with no devices should produce an empty inventory.") + try require( + snapshot.xcodeURL.path == xcode.path, + "The selected Xcode should be recognized even if Launch Services has not registered it.") + } + + static func require(_ condition: Bool, _ message: String) throws { + if !condition { throw failure(message) } + } + + static func failure(_ message: String) -> NSError { + NSError( + domain: "SimulatorDiscoveryTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} From a41e6042118356b97282d5531cd38cd2821d0130 Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Wed, 4 Nov 2026 11:51:21 -0500 Subject: [PATCH 2/5] Split simulator setup views out of ContentView --- src/CMakeLists.txt | 4 + src/ContentView.swift | 327 ++++----------------------------- src/MissingXcodeView.swift | 42 +++++ src/NoSimulatorsView.swift | 85 +++++++++ src/SidebarView.swift | 120 ++++++++++++ src/SimulatorStatusViews.swift | 77 ++++++++ 6 files changed, 366 insertions(+), 289 deletions(-) create mode 100644 src/MissingXcodeView.swift create mode 100644 src/NoSimulatorsView.swift create mode 100644 src/SidebarView.swift create mode 100644 src/SimulatorStatusViews.swift diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e97da92..0515c43 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,6 +3,10 @@ add_executable(SimStore MACOSX_BUNDLE SimStoreApp.swift SimulatorDiscovery.swift ContentView.swift + SidebarView.swift + NoSimulatorsView.swift + MissingXcodeView.swift + SimulatorStatusViews.swift ) find_program(RSVG_CONVERT_EXECUTABLE rsvg-convert REQUIRED) diff --git a/src/ContentView.swift b/src/ContentView.swift index 8dfbd25..f069cd4 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -35,8 +35,11 @@ struct ContentView: View { var body: some View { NavigationSplitView { GeometryReader { geometry in - sidebar - .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) + SidebarView( + discovery: discovery, isChecking: isChecking, needsXcode: needsXcode, + openRepository: openRepository + ) + .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) } .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 260) } detail: { @@ -69,9 +72,7 @@ struct ContentView: View { .fixedSize() } ToolbarItem(placement: .primaryAction) { - Button { - Task { await refresh() } - } label: { + Button(action: checkAgain) { Label("Check for Simulators", systemImage: "arrow.clockwise") } .disabled(isChecking) @@ -85,7 +86,7 @@ struct ContentView: View { .task { await refresh() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - Task { await refresh() } + checkAgain() } .alert( "Couldn’t Open Application", @@ -100,300 +101,28 @@ struct ContentView: View { } } - private var sidebar: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 10) { - Image(nsImage: NSApplication.shared.applicationIconImage) - .resizable() - .frame(width: 37, height: 37) - Text("SimStore") - .font(.system(size: 22, weight: .bold)) - .tracking(-0.7) - } - .padding(.horizontal, 20) - .padding(.top, 24) - .padding(.bottom, 27) - .fixedSize(horizontal: false, vertical: true) - - VStack(spacing: 5) { - Label("Discover", systemImage: "safari") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Color.accentColor) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 11) - .background(Color.accentColor.opacity(0.10), in: RoundedRectangle(cornerRadius: 8)) - .accessibilityAddTraits(.isSelected) - sidebarPlaceholder("Installed", systemImage: "square.grid.2x2") - sidebarPlaceholder("Updates", systemImage: "arrow.triangle.2.circlepath") - } - .padding(.horizontal, 12) - .fixedSize(horizontal: false, vertical: true) - - Text("SIMULATORS") - .font(.system(size: 10, weight: .semibold)) - .tracking(1) - .foregroundStyle(.secondary) - .padding(.horizontal, 24) - .padding(.top, 32) - .padding(.bottom, 15) - .fixedSize(horizontal: false, vertical: true) - - if let discovery, !discovery.devices.isEmpty { - ScrollView { - LazyVStack(alignment: .leading, spacing: 16) { - ForEach(discovery.devices) { device in - Label { - VStack(alignment: .leading, spacing: 4) { - Text(device.name).font(.system(size: 12, weight: .medium)) - Text("iOS \(device.runtime) · \(device.state)") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - } - .lineLimit(1) - .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) - } icon: { - Image(systemName: device.name.contains("iPad") ? "ipad.landscape" : "iphone") - .foregroundStyle(.secondary) - } - } - } - .padding(.horizontal, 24) - .padding(.vertical, 4) - } - .frame(minHeight: 0) - } else { - VStack(alignment: .leading, spacing: 7) { - Text( - isChecking - ? "Checking for devices…" - : needsXcode - ? "Xcode required" - : discovery == nil ? "No devices to display" : "No simulators yet" - ) - .font(.system(size: 12, weight: .medium)) - Text( - needsXcode - ? "Install Xcode to add iPhone and iPad simulators." - : "Your iPhone and iPad simulators will appear here." - ) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineSpacing(3) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(14) - .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal, 16) - } - - Spacer(minLength: 24) - - Button { - if !NSWorkspace.shared.open(URL(string: "https://github.com/eagleoflqj/simstore-builds")!) { - launchError = "Open https://github.com/eagleoflqj/simstore-builds in your web browser." - } - } label: { - Label("App Repository", systemImage: "arrow.up.right.square") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .padding(24) - .fixedSize(horizontal: false, vertical: true) - } - } - - private func sidebarPlaceholder(_ title: String, systemImage: String) -> some View { - Label(title, systemImage: systemImage) - .font(.system(size: 13)) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 11) - } - @ViewBuilder private var discoveryContent: some View { switch result { case .none: - VStack(spacing: 15) { - ProgressView().controlSize(.small) - Text("Looking for your simulators…") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } + SimulatorLoadingView() case .some(.success(let snapshot)) where snapshot.devices.isEmpty: - emptyState + NoSimulatorsView( + deviceManagerName: deviceManager?.name, isChecking: isChecking, + openDeviceManager: openDeviceManager, openXcode: openXcode, checkAgain: checkAgain) case .some(.success(let snapshot)): - VStack(spacing: 14) { - Image(systemName: "iphone.and.arrow.forward") - .font(.system(size: 48, weight: .light)) - .foregroundStyle(Color.accentColor) - Text("Your simulators are ready") - .font(.system(size: 24, weight: .semibold)) - Text( - "Found \(snapshot.devices.count) available iOS \(snapshot.devices.count == 1 ? "simulator" : "simulators")." - ) - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } + SimulatorsReadyView(count: snapshot.devices.count) case .some(.failure) where needsXcode: - missingXcodeState + MissingXcodeView( + isChecking: isChecking, openAppStore: openAppStore, checkAgain: checkAgain) case .some(.failure(let error)): - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 44, weight: .light)) - .foregroundStyle(.orange) - Text("Couldn’t check for simulators") - .font(.system(size: 24, weight: .semibold)) - Text( - "Make sure Xcode is installed and its command-line tools are selected, then try again." - ) - .font(.system(size: 13)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 390) - HStack(spacing: 10) { - Button("Open Xcode", systemImage: "hammer") { openXcode() } - Button(isChecking ? "Checking…" : "Try Again", systemImage: "arrow.clockwise") { - Task { await refresh() } - } - .disabled(isChecking) - } - .controlSize(.large) - DisclosureGroup("Details") { - Text(error.localizedDescription) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 8) - } - .font(.system(size: 11)) - .frame(maxWidth: 390) - } - .padding(30) + SimulatorDiscoveryErrorView( + error: error, isChecking: isChecking, openXcode: openXcode, checkAgain: checkAgain) } } - private var missingXcodeState: some View { - VStack(spacing: 0) { - Image(systemName: "hammer") - .font(.system(size: 58, weight: .light)) - .foregroundStyle(Color.accentColor) - .frame(width: 130, height: 130) - .background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 30)) - .padding(.bottom, 30) - Text("Start with Xcode.") - .font(.system(size: 25, weight: .semibold)) - .tracking(-0.6) - Text( - "Xcode provides the tools SimStore needs to run iOS apps.\nInstall it from the App Store, then open it to finish setup." - ) - .font(.system(size: 13)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .lineSpacing(5) - .padding(.top, 12) - HStack(spacing: 10) { - Button("Open App Store", systemImage: "bag") { - if !NSWorkspace.shared.open(URL(string: "macappstore://apps.apple.com/app/id497799835")!) - { - launchError = "Open the App Store and search for Xcode." - } - } - .buttonStyle(.borderedProminent) - Button(isChecking ? "Checking…" : "Check Again", systemImage: "arrow.clockwise") { - Task { await refresh() } - } - .buttonStyle(.bordered) - .disabled(isChecking) - } - .controlSize(.large) - .padding(.top, 25) - } - .padding(.horizontal, 30) - } - - private var emptyState: some View { - VStack(spacing: 0) { - ZStack { - Circle() - .fill( - RadialGradient( - colors: [Color.accentColor.opacity(0.08), Color.accentColor.opacity(0)], - center: .center, startRadius: 35, endRadius: 125) - ) - .frame(width: 250, height: 200) - Image(systemName: "ipad.landscape") - .font(.system(size: 78, weight: .ultraLight)) - .foregroundStyle(Color.secondary.opacity(0.25)) - .rotationEffect(.degrees(-9)) - .offset(x: -35, y: 12) - Image(systemName: "iphone.gen3") - .font(.system(size: 112, weight: .ultraLight)) - .foregroundStyle(Color.accentColor.opacity(0.85)) - .rotationEffect(.degrees(9)) - .offset(x: 36, y: -6) - } - .accessibilityHidden(true) - - Text("A simulator is where it starts.") - .font(.system(size: 25, weight: .semibold)) - .tracking(-0.6) - .padding(.top, 6) - Text( - "Create an iPhone or iPad simulator in \(deviceManager?.name ?? "Xcode").\nThen come back to find your next favorite app." - ) - .font(.system(size: 13)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .lineSpacing(5) - .padding(.top, 12) - - HStack(spacing: 10) { - Button( - "Open \(deviceManager?.name ?? "Xcode")", - systemImage: deviceManager?.name == nil ? "hammer" : "iphone.and.arrow.forward" - ) { - if let deviceManager { - openApplication(deviceManager.url, name: deviceManager.name) - } else { - openXcode() - } - } - .buttonStyle(.borderedProminent) - Button(isChecking ? "Checking…" : "Check Again", systemImage: "arrow.clockwise") { - Task { await refresh() } - } - .buttonStyle(.bordered) - .disabled(isChecking) - } - .controlSize(.large) - .padding(.top, 25) - - if deviceManager?.name != nil { - Button { - openXcode() - } label: { - HStack(spacing: 5) { - Text("Open Xcode") - Image(systemName: "arrow.up.right").font(.system(size: 9, weight: .medium)) - } - .font(.system(size: 11)) - } - .buttonStyle(.link) - .padding(.top, 18) - } else { - Text("Device Hub or Simulator couldn’t be found in the selected Xcode installation.") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .padding(.top, 18) - } - } - .padding(.horizontal, 30) + private func checkAgain() { + Task { await refresh() } } private func refresh() async { @@ -411,6 +140,26 @@ struct ContentView: View { } } + private func openRepository() { + if !NSWorkspace.shared.open(URL(string: "https://github.com/eagleoflqj/simstore-builds")!) { + launchError = "Open https://github.com/eagleoflqj/simstore-builds in your web browser." + } + } + + private func openAppStore() { + if !NSWorkspace.shared.open(URL(string: "macappstore://apps.apple.com/app/id497799835")!) { + launchError = "Open the App Store and search for Xcode." + } + } + + private func openDeviceManager() { + if let deviceManager { + openApplication(deviceManager.url, name: deviceManager.name) + } else { + openXcode() + } + } + private func openXcode() { openApplication( discovery?.xcodeURL diff --git a/src/MissingXcodeView.swift b/src/MissingXcodeView.swift new file mode 100644 index 0000000..ce77d6a --- /dev/null +++ b/src/MissingXcodeView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +@MainActor +struct MissingXcodeView: View { + let isChecking: Bool + let openAppStore: () -> Void + let checkAgain: () -> Void + + var body: some View { + VStack(spacing: 0) { + Image(systemName: "hammer") + .font(.system(size: 58, weight: .light)) + .foregroundStyle(Color.accentColor) + .frame(width: 130, height: 130) + .background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 30)) + .padding(.bottom, 30) + Text("Start with Xcode.") + .font(.system(size: 25, weight: .semibold)) + .tracking(-0.6) + Text( + "Xcode provides the tools SimStore needs to run iOS apps.\nInstall it from the App Store, then open it to finish setup." + ) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(5) + .padding(.top, 12) + HStack(spacing: 10) { + Button("Open App Store", systemImage: "bag", action: openAppStore) + .buttonStyle(.borderedProminent) + Button(isChecking ? "Checking…" : "Check Again", systemImage: "arrow.clockwise") { + checkAgain() + } + .buttonStyle(.bordered) + .disabled(isChecking) + } + .controlSize(.large) + .padding(.top, 25) + } + .padding(.horizontal, 30) + } +} diff --git a/src/NoSimulatorsView.swift b/src/NoSimulatorsView.swift new file mode 100644 index 0000000..71dfbff --- /dev/null +++ b/src/NoSimulatorsView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +@MainActor +struct NoSimulatorsView: View { + let deviceManagerName: String? + let isChecking: Bool + let openDeviceManager: () -> Void + let openXcode: () -> Void + let checkAgain: () -> Void + + var body: some View { + VStack(spacing: 0) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [Color.accentColor.opacity(0.08), Color.accentColor.opacity(0)], + center: .center, startRadius: 35, endRadius: 125) + ) + .frame(width: 250, height: 200) + Image(systemName: "ipad.landscape") + .font(.system(size: 78, weight: .ultraLight)) + .foregroundStyle(Color.secondary.opacity(0.25)) + .rotationEffect(.degrees(-9)) + .offset(x: -35, y: 12) + Image(systemName: "iphone.gen3") + .font(.system(size: 112, weight: .ultraLight)) + .foregroundStyle(Color.accentColor.opacity(0.85)) + .rotationEffect(.degrees(9)) + .offset(x: 36, y: -6) + } + .accessibilityHidden(true) + + Text("A simulator is where it starts.") + .font(.system(size: 25, weight: .semibold)) + .tracking(-0.6) + .padding(.top, 6) + Text( + "Create an iPhone or iPad simulator in \(deviceManagerName ?? "Xcode").\nThen come back to find your next favorite app." + ) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(5) + .padding(.top, 12) + + HStack(spacing: 10) { + Button( + "Open \(deviceManagerName ?? "Xcode")", + systemImage: deviceManagerName == nil ? "hammer" : "iphone.and.arrow.forward" + ) { + openDeviceManager() + } + .buttonStyle(.borderedProminent) + Button(isChecking ? "Checking…" : "Check Again", systemImage: "arrow.clockwise") { + checkAgain() + } + .buttonStyle(.bordered) + .disabled(isChecking) + } + .controlSize(.large) + .padding(.top, 25) + + if deviceManagerName != nil { + Button { + openXcode() + } label: { + HStack(spacing: 5) { + Text("Open Xcode") + Image(systemName: "arrow.up.right").font(.system(size: 9, weight: .medium)) + } + .font(.system(size: 11)) + } + .buttonStyle(.link) + .padding(.top, 18) + } else { + Text("Device Hub or Simulator couldn’t be found in the selected Xcode installation.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(.top, 18) + } + } + .padding(.horizontal, 30) + } +} diff --git a/src/SidebarView.swift b/src/SidebarView.swift new file mode 100644 index 0000000..41e7ceb --- /dev/null +++ b/src/SidebarView.swift @@ -0,0 +1,120 @@ +import AppKit +import SwiftUI + +@MainActor +struct SidebarView: View { + let discovery: SimulatorDiscovery? + let isChecking: Bool + let needsXcode: Bool + let openRepository: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .frame(width: 37, height: 37) + Text("SimStore") + .font(.system(size: 22, weight: .bold)) + .tracking(-0.7) + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 27) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 5) { + Label("Discover", systemImage: "safari") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 11) + .background(Color.accentColor.opacity(0.10), in: RoundedRectangle(cornerRadius: 8)) + .accessibilityAddTraits(.isSelected) + sidebarPlaceholder("Installed", systemImage: "square.grid.2x2") + sidebarPlaceholder("Updates", systemImage: "arrow.triangle.2.circlepath") + } + .padding(.horizontal, 12) + .fixedSize(horizontal: false, vertical: true) + + Text("SIMULATORS") + .font(.system(size: 10, weight: .semibold)) + .tracking(1) + .foregroundStyle(.secondary) + .padding(.horizontal, 24) + .padding(.top, 32) + .padding(.bottom, 15) + .fixedSize(horizontal: false, vertical: true) + + if let discovery, !discovery.devices.isEmpty { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(discovery.devices) { device in + Label { + VStack(alignment: .leading, spacing: 4) { + Text(device.name).font(.system(size: 12, weight: .medium)) + Text("iOS \(device.runtime) · \(device.state)") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } icon: { + Image(systemName: device.name.contains("iPad") ? "ipad.landscape" : "iphone") + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 24) + .padding(.vertical, 4) + } + .frame(minHeight: 0) + } else { + VStack(alignment: .leading, spacing: 7) { + Text( + isChecking + ? "Checking for devices…" + : needsXcode + ? "Xcode required" + : discovery == nil ? "No devices to display" : "No simulators yet" + ) + .font(.system(size: 12, weight: .medium)) + Text( + needsXcode + ? "Install Xcode to add iPhone and iPad simulators." + : "Your iPhone and iPad simulators will appear here." + ) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineSpacing(3) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal, 16) + } + + Spacer(minLength: 24) + + Button(action: openRepository) { + Label("App Repository", systemImage: "arrow.up.right.square") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(24) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func sidebarPlaceholder(_ title: String, systemImage: String) -> some View { + Label(title, systemImage: systemImage) + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 11) + } +} diff --git a/src/SimulatorStatusViews.swift b/src/SimulatorStatusViews.swift new file mode 100644 index 0000000..d611b1d --- /dev/null +++ b/src/SimulatorStatusViews.swift @@ -0,0 +1,77 @@ +import SwiftUI + +@MainActor +struct SimulatorLoadingView: View { + var body: some View { + VStack(spacing: 15) { + ProgressView().controlSize(.small) + Text("Looking for your simulators…") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + } +} + +@MainActor +struct SimulatorsReadyView: View { + let count: Int + + var body: some View { + VStack(spacing: 14) { + Image(systemName: "iphone.and.arrow.forward") + .font(.system(size: 48, weight: .light)) + .foregroundStyle(Color.accentColor) + Text("Your simulators are ready") + .font(.system(size: 24, weight: .semibold)) + Text( + "Found \(count) available iOS \(count == 1 ? "simulator" : "simulators")." + ) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + } +} + +@MainActor +struct SimulatorDiscoveryErrorView: View { + let error: Error + let isChecking: Bool + let openXcode: () -> Void + let checkAgain: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 44, weight: .light)) + .foregroundStyle(.orange) + Text("Couldn’t check for simulators") + .font(.system(size: 24, weight: .semibold)) + Text( + "Make sure Xcode is installed and its command-line tools are selected, then try again." + ) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 390) + HStack(spacing: 10) { + Button("Open Xcode", systemImage: "hammer") { openXcode() } + Button(isChecking ? "Checking…" : "Try Again", systemImage: "arrow.clockwise") { + checkAgain() + } + .disabled(isChecking) + } + .controlSize(.large) + DisclosureGroup("Details") { + Text(error.localizedDescription) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + } + .font(.system(size: 11)) + .frame(maxWidth: 390) + } + .padding(30) + } +} From f58ceb9a5026170fb9915f99ba90f26e04a41139 Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Wed, 4 Nov 2026 11:56:30 -0500 Subject: [PATCH 3/5] Update builds repository URL to iSimStore --- src/ContentView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ContentView.swift b/src/ContentView.swift index f069cd4..88cec7e 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -141,8 +141,8 @@ struct ContentView: View { } private func openRepository() { - if !NSWorkspace.shared.open(URL(string: "https://github.com/eagleoflqj/simstore-builds")!) { - launchError = "Open https://github.com/eagleoflqj/simstore-builds in your web browser." + if !NSWorkspace.shared.open(URL(string: "https://github.com/iSimStore/simstore-builds")!) { + launchError = "Open https://github.com/iSimStore/simstore-builds in your web browser." } } From 0e24706d586c529a7ec983b4f4c16fb5912acdfa Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Wed, 4 Nov 2026 12:02:28 -0500 Subject: [PATCH 4/5] Centralize the builds repository URL --- src/AppConfiguration.swift | 5 +++++ src/CMakeLists.txt | 1 + src/ContentView.swift | 5 +++-- 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 src/AppConfiguration.swift diff --git a/src/AppConfiguration.swift b/src/AppConfiguration.swift new file mode 100644 index 0000000..b3777f6 --- /dev/null +++ b/src/AppConfiguration.swift @@ -0,0 +1,5 @@ +import Foundation + +enum AppConfiguration { + static let buildsRepositoryURL = URL(string: "https://github.com/iSimStore/simstore-builds")! +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0515c43..5f79f15 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,5 +1,6 @@ add_executable(SimStore MACOSX_BUNDLE AppDelegate.swift + AppConfiguration.swift SimStoreApp.swift SimulatorDiscovery.swift ContentView.swift diff --git a/src/ContentView.swift b/src/ContentView.swift index 88cec7e..9a1e71c 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -141,8 +141,9 @@ struct ContentView: View { } private func openRepository() { - if !NSWorkspace.shared.open(URL(string: "https://github.com/iSimStore/simstore-builds")!) { - launchError = "Open https://github.com/iSimStore/simstore-builds in your web browser." + let url = AppConfiguration.buildsRepositoryURL + if !NSWorkspace.shared.open(url) { + launchError = "Open \(url.absoluteString) in your web browser." } } From 704d93bd2694bdcf2583a630bd6a36825d9d10aa Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Wed, 4 Nov 2026 12:13:43 -0500 Subject: [PATCH 5/5] Share the installed Xcode lookup --- src/ContentView.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ContentView.swift b/src/ContentView.swift index 9a1e71c..e2a172b 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -8,11 +8,13 @@ struct ContentView: View { @State private var isChecking = false @State private var launchError: String? + private static var installedXcodeURL: URL? { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode") + } + init( discover: @escaping @MainActor () async throws -> SimulatorDiscovery = { - try await SimulatorDiscovery.scan( - installedXcodeURL: NSWorkspace.shared.urlForApplication( - withBundleIdentifier: "com.apple.dt.Xcode")) + try await SimulatorDiscovery.scan(installedXcodeURL: ContentView.installedXcodeURL) } ) { self.discover = discover @@ -162,10 +164,7 @@ struct ContentView: View { } private func openXcode() { - openApplication( - discovery?.xcodeURL - ?? NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode"), - name: "Xcode") + openApplication(discovery?.xcodeURL ?? Self.installedXcodeURL, name: "Xcode") } private func openApplication(_ url: URL?, name: String) {