From 299c8f38606df7fe3ed550b3fbdf7b10d873c3b3 Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Wed, 4 Nov 2026 16:00:26 -0500 Subject: [PATCH] Add Debug-only setup screen previews --- .github/workflows/build.yml | 5 ++ docs/previews.md | 30 ++++++++ src/CMakeLists.txt | 3 + src/ContentView.swift | 7 +- src/PreviewScenario.swift | 41 ++++++++++ tests/CMakeLists.txt | 11 +++ tests/PreviewScenarioTests.swift | 125 +++++++++++++++++++++++++++++++ 7 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 docs/previews.md create mode 100644 src/PreviewScenario.swift create mode 100644 tests/PreviewScenarioTests.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7834586..c65fc75 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/docs/previews.md b/docs/previews.md new file mode 100644 index 0000000..999f16b --- /dev/null +++ b/docs/previews.md @@ -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. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2a31ed7..deb5fb8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -10,8 +10,11 @@ add_executable(SimStore MACOSX_BUNDLE SimulatorStatusViews.swift StableSizeView.swift SimulatorCheckButton.swift + PreviewScenario.swift ) +target_compile_definitions(SimStore PRIVATE "$<$:DEBUG>") + include("${PROJECT_SOURCE_DIR}/cmake/Localization.cmake") find_program(RSVG_CONVERT_EXECUTABLE rsvg-convert REQUIRED) diff --git a/src/ContentView.swift b/src/ContentView.swift index 019d699..393f6a9 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -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 diff --git a/src/PreviewScenario.swift b/src/PreviewScenario.swift new file mode 100644 index 0000000..80e0674 --- /dev/null +++ b/src/PreviewScenario.swift @@ -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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1269e4a..316df6a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,17 @@ target_compile_options(SimStoreStableSizeTest PRIVATE -parse-as-library) add_test(NAME stable-button-size COMMAND SimStoreStableSizeTest "$") 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 diff --git a/tests/PreviewScenarioTests.swift b/tests/PreviewScenarioTests.swift new file mode 100644 index 0000000..493c5f9 --- /dev/null +++ b/tests/PreviewScenarioTests.swift @@ -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 } } +}