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 .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ jobs:
git diff --exit-code -- assets/Localizable.xcstrings
- name: Test
run: ctest --preset release --verbose
- name: Test Debug previews
run: |
cmake --preset debug
cmake --build --preset debug --target SimStore SimStorePreviewScenarioTest
ctest --preset debug --verbose -R '^preview-scenarios$'

publish:
needs: build
Expand Down
30 changes: 30 additions & 0 deletions docs/previews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Setup screen 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.

Build the Debug application:

```sh
cmake --preset debug
cmake --build --preset debug
```

Preview a machine without Xcode:

```sh
open -n build/debug/SimStore.app --args --preview no-xcode
```

Preview Xcode without available iOS simulators:

```sh
open -n build/debug/SimStore.app --args --preview no-simulators
```

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.
3 changes: 3 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ add_executable(SimStore MACOSX_BUNDLE
SimulatorStatusViews.swift
StableSizeView.swift
SimulatorCheckButton.swift
PreviewScenario.swift
)

target_compile_definitions(SimStore PRIVATE "$<$<CONFIG:Debug>:DEBUG>")

include("${PROJECT_SOURCE_DIR}/cmake/Localization.cmake")

find_program(RSVG_CONVERT_EXECUTABLE rsvg-convert REQUIRED)
Expand Down
7 changes: 6 additions & 1 deletion src/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ struct ContentView: View {

init(
discover: @escaping @MainActor () async throws -> SimulatorDiscovery = {
try await SimulatorDiscovery.scan(installedXcodeURL: ContentView.installedXcodeURL)
#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)
}
) {
self.discover = discover
Expand Down
41 changes: 41 additions & 0 deletions src/PreviewScenario.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#if DEBUG
import Foundation

enum PreviewScenario: String, Sendable {
case noXcode = "no-xcode"
case noSimulators = "no-simulators"

static func parse(_ arguments: [String]) throws -> Self? {
guard let index = arguments.firstIndex(of: "--preview") else { return nil }
guard arguments.indices.contains(index + 1),
let scenario = Self(rawValue: arguments[index + 1])
else {
throw NSError(
domain: "PreviewScenario", code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Use --preview no-xcode or --preview no-simulators."
])
}
return scenario
}

func discover(
installedXcodeURL: URL?,
command: @escaping @Sendable (String, [String]) throws -> Data = SimulatorDiscovery
.commandOutput
) async throws -> SimulatorDiscovery {
switch self {
case .noXcode:
throw SimulatorDiscoveryError.xcodeNotInstalled
case .noSimulators:
return try await SimulatorDiscovery.scan(installedXcodeURL: installedXcodeURL) {
executable, arguments in
if executable == "/usr/bin/xcrun", arguments.starts(with: ["simctl", "list", "devices"]) {
return Data(#"{"devices": {}}"#.utf8)
}
return try command(executable, arguments)
}
}
}
}
#endif
11 changes: 11 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ target_compile_options(SimStoreStableSizeTest PRIVATE -parse-as-library)
add_test(NAME stable-button-size COMMAND SimStoreStableSizeTest "$<TARGET_BUNDLE_DIR:SimStore>")
set_tests_properties(stable-button-size PROPERTIES TIMEOUT 15 RUN_SERIAL TRUE)

if(CMAKE_BUILD_TYPE STREQUAL "Debug")
add_executable(SimStorePreviewScenarioTest PreviewScenarioTests.swift
"${PROJECT_SOURCE_DIR}/src/PreviewScenario.swift"
"${PROJECT_SOURCE_DIR}/src/SimulatorDiscovery.swift"
)
target_compile_definitions(SimStorePreviewScenarioTest PRIVATE DEBUG)
target_compile_options(SimStorePreviewScenarioTest PRIVATE -parse-as-library)
add_test(NAME preview-scenarios COMMAND SimStorePreviewScenarioTest)
set_tests_properties(preview-scenarios PROPERTIES TIMEOUT 15)
endif()

execute_process(COMMAND xcrun --sdk "${CMAKE_OSX_SYSROOT}" --show-sdk-version
OUTPUT_VARIABLE SDK_VERSION
OUTPUT_STRIP_TRAILING_WHITESPACE
Expand Down
125 changes: 125 additions & 0 deletions tests/PreviewScenarioTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import Foundation

@main
struct PreviewScenarioTests {
static func main() async {
do {
try arguments()
try await missingXcode()
try await emptyInventory()
print("PASS: Debug preview arguments, forced missing Xcode, and empty simulator inventory.")
} catch {
FileHandle.standardError.write(
Data("Preview scenario test failed: \(error.localizedDescription)\n".utf8))
exit(EXIT_FAILURE)
}
}

static func arguments() throws {
for args in [["SimStore"], ["SimStore", "-AppleLanguages", "(zh-Hans)"]] {
try require(
try PreviewScenario.parse(args) == nil, "Normal launches must not enable previews.")
}
for mode in ["no-xcode", "no-simulators"] {
let parsed = try PreviewScenario.parse([
"SimStore", "-AppleLanguages", "(zh-Hans)", "--preview", mode,
])
try require(parsed?.rawValue == mode, "A valid preview mode must be recognized.")
}
for args in [["SimStore", "--preview"], ["SimStore", "--preview", "unknown"]] {
do {
_ = try PreviewScenario.parse(args)
} catch { continue }
throw failure("Invalid preview arguments must be rejected.")
}
}

static func missingXcode() async throws {
let calls = CommandCalls()
do {
_ = try await PreviewScenario.noXcode.discover(installedXcodeURL: nil) { _, _ in
calls.record()
throw failure("A forced missing-Xcode preview must not run any commands.")
}
throw failure("The missing-Xcode preview must require installation.")
} catch {
try require(
error as? SimulatorDiscoveryError == .xcodeNotInstalled,
"The missing-Xcode preview must report the installation state.")
}
try require(calls.count == 0, "The missing-Xcode preview queried the host toolchain.")
}

static func emptyInventory() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
"SimStore Preview \(UUID().uuidString)")
let xcode = root.appendingPathComponent("Xcode Beta.app")
let developer = xcode.appendingPathComponent("Contents/Developer")
let manager = xcode.appendingPathComponent("Contents/Applications/DeviceHub.app")
try FileManager.default.createDirectory(at: developer, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: manager, 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"))

let calls = CommandCalls()
let snapshot = try await PreviewScenario.noSimulators.discover(installedXcodeURL: nil) {
executable, args in
calls.record()
try require(
executable == "/usr/bin/xcode-select" && args == ["--print-path"],
"The empty-inventory preview must not call the real simctl command.")
return Data("\(developer.path)\n".utf8)
}
try require(snapshot.devices.isEmpty, "The preview must return an empty device inventory.")
try require(calls.count == 1, "The selected developer directory must still be queried.")
try require(
snapshot.developerDirectory.path == developer.path, "The selected developer path was lost.")
try require(snapshot.xcodeURL.path == xcode.path, "The selected Xcode application was lost.")
try require(
snapshot.deviceManager?.url.path == manager.path,
"The empty-inventory preview must preserve Device Hub discovery.")

do {
_ = try await PreviewScenario.noSimulators.discover(installedXcodeURL: nil) { _, _ in
Data("/Library/Developer/CommandLineTools\n".utf8)
}
throw failure("An empty-inventory preview must still require a full Xcode installation.")
} catch {
try require(
error as? SimulatorDiscoveryError == .xcodeNotInstalled,
"Missing Xcode must not be disguised as an empty simulator list.")
}
do {
_ = try await PreviewScenario.noSimulators.discover(installedXcodeURL: xcode) { _, _ in
throw failure("Toolchain configuration failed")
}
throw failure("An empty-inventory preview must preserve toolchain errors.")
} catch {
try require(
(error as NSError).domain == "PreviewScenarioTests"
&& error.localizedDescription == "Toolchain configuration failed",
"The original toolchain failure was not preserved.")
}
}

static func require(_ condition: Bool, _ message: String) throws {
if !condition { throw failure(message) }
}

static func failure(_ message: String) -> NSError {
NSError(domain: "PreviewScenarioTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
}
}

private final class CommandCalls: @unchecked Sendable {
private let lock = NSLock()
private var value = 0

var count: Int { lock.withLock { value } }
func record() { lock.withLock { value += 1 } }
}