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
5 changes: 5 additions & 0 deletions src/AppConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Foundation

enum AppConfiguration {
static let buildsRepositoryURL = URL(string: "https://github.com/iSimStore/simstore-builds")!
}
6 changes: 6 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
add_executable(SimStore MACOSX_BUNDLE
AppDelegate.swift
AppConfiguration.swift
SimStoreApp.swift
SimulatorDiscovery.swift
ContentView.swift
SidebarView.swift
NoSimulatorsView.swift
MissingXcodeView.swift
SimulatorStatusViews.swift
)

find_program(RSVG_CONVERT_EXECUTABLE rsvg-convert REQUIRED)
Expand Down
185 changes: 171 additions & 14 deletions src/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,183 @@ import SwiftUI

@MainActor
struct ContentView: View {
@State private var cannotOpenRepository = false
private let discover: @MainActor () async throws -> SimulatorDiscovery
@State private var result: Result<SimulatorDiscovery, Error>?
@State private var isChecking = false
@State private var launchError: String?

private let repositoryURL = URL(string: "https://github.com/eagleoflqj/simstore-builds")!
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: ContentView.installedXcodeURL)
}
) {
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
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: {
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(action: checkAgain) {
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
checkAgain()
}
.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 ?? "")
}
}

@ViewBuilder
private var discoveryContent: some View {
switch result {
case .none:
SimulatorLoadingView()
case .some(.success(let snapshot)) where snapshot.devices.isEmpty:
NoSimulatorsView(
deviceManagerName: deviceManager?.name, isChecking: isChecking,
openDeviceManager: openDeviceManager, openXcode: openXcode, checkAgain: checkAgain)
case .some(.success(let snapshot)):
SimulatorsReadyView(count: snapshot.devices.count)
case .some(.failure) where needsXcode:
MissingXcodeView(
isChecking: isChecking, openAppStore: openAppStore, checkAgain: checkAgain)
case .some(.failure(let error)):
SimulatorDiscoveryErrorView(
error: error, isChecking: isChecking, openXcode: openXcode, checkAgain: checkAgain)
}
}

private func checkAgain() {
Task { await refresh() }
}

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 openRepository() {
let url = AppConfiguration.buildsRepositoryURL
if !NSWorkspace.shared.open(url) {
launchError = "Open \(url.absoluteString) 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 ?? Self.installedXcodeURL, 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)"
}
}
}
}
}
42 changes: 42 additions & 0 deletions src/MissingXcodeView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
85 changes: 85 additions & 0 deletions src/NoSimulatorsView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading