├── .gitignore ├── .spi.yml ├── Images └── readme-screenshot.jpg ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── ExperimentsBoard │ ├── Core │ ├── Foundation - LockedState.swift │ ├── Snapshot API.swift │ ├── Static API - Wrapper.swift │ ├── Static API.swift │ ├── Storage - Experiment Kinds.swift │ ├── Storage - Observable.swift │ ├── Storage - Weak Observation.swift │ └── Storage.swift │ ├── ExperimentsBoard.docc │ ├── Documentation.md │ ├── Getting Started.md │ ├── Platform Support.md │ ├── Resources │ │ ├── ios-intro.jpg │ │ ├── ios-intro@2x.jpg │ │ ├── ios-overlay.jpg │ │ ├── ios-overlay@2x.jpg │ │ ├── macos-intro.jpg │ │ ├── macos-intro@2x.jpg │ │ ├── tvos-editor.jpg │ │ └── tvos-editor@2x.jpg │ └── What's New.md │ └── UI │ ├── Autodisplay.swift │ ├── Control Overlay.swift │ ├── Editor - Form.swift │ ├── Editor - Rows.swift │ ├── Editor.swift │ ├── Scene.swift │ └── Tools.swift └── Tests └── ExperimentsBoardTests ├── Tests - Observables.swift ├── Tests - Storage.swift └── Tests - Wrapper.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm 7 | .netrc 8 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [ExperimentsBoard] 5 | -------------------------------------------------------------------------------- /Images/readme-screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noeticgarden/experimentsboard/47b98a0bc0d0e644d32ad92351d06aca049727cc/Images/readme-screenshot.jpg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The following applies to all files in this repository except where those files expressely indicate otherwise. 2 | 3 | ----- 4 | 5 | Copyright 2024, Aura Vulcano. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | 13 | ----- -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "234791d796ae09c5f8f00abe717a22eedf8a03dced5d7b93f6ba848ac3f0ac44", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-docc-plugin", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-docc-plugin", 8 | "state" : { 9 | "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", 10 | "version" : "1.4.3" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-docc-symbolkit", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/swiftlang/swift-docc-symbolkit", 17 | "state" : { 18 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 19 | "version" : "1.0.0" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ExperimentsBoard", 7 | platforms: [.iOS(.v14), .tvOS(.v14), .watchOS(.v7), .macOS(.v11)], 8 | products: [ 9 | .library( 10 | name: "ExperimentsBoard", 11 | targets: ["ExperimentsBoard"]), 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.3"), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "ExperimentsBoard", 19 | swiftSettings: [ 20 | /* Uncomment this to build as if SwiftUI was not available (e.g., to test building for Linux or WASI). 21 | You must define this if EXPERIMENT_BOARD_DO_NOT_USE_OBSERVATION is set and SwiftUI is available. */ 22 | // .define("EXPERIMENT_BOARD_DO_NOT_USE_SWIFTUI") 23 | 24 | /* Uncomment this to build as if Observation was not available (e.g., to test building for WASI). */ 25 | // .define("EXPERIMENT_BOARD_DO_NOT_USE_OBSERVATION") 26 | ] 27 | ), 28 | .testTarget( 29 | name: "ExperimentsBoardTests", 30 | dependencies: ["ExperimentsBoard"] 31 | ), 32 | ], 33 | swiftLanguageVersions: [.v5, .version("6")] 34 | ) 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExperimentsBoard 2 | 3 | _A work of Noetic Garden — by [millenomi](https://millenomi.name)._ 4 | 5 | The `ExperimentsBoard` module allows you to quickly add UI to turn any constant in your code into something you can tweak and experiment with at runtime. 6 | 7 | All it takes is wrapping a constant in a call to `Experiments.value(…)` — for example: 8 | 9 | ```swift 10 | Text("Hello, world!") 11 | 12 | // … turns into: 13 | 14 | Text("Hello, \(Experiments.value("world", key: "Place"))!") 15 | ``` 16 | 17 | Once you do this, just add a couple lines to your app to enable displaying the editing UI, and turn any part of your app into something you can tweak and experiment with without having to roundtrip: 18 | 19 | ![A screenshot of the editing UI on macOS and iOS](Images/readme-screenshot.jpg) 20 | 21 | The UI in this module works on iOS 17, macOS 14, tvOS 17, watchOS 10 and visionOS 1 or later, but the core of it is cross-platform — bring your own UI and you can use the same experiment syntax on prior OSes, or on Linux, WebAssembly, or Windows. On those platforms, beside DocC, the only dependency for this module is Swift itself. 22 | 23 | The module is fully documented — build the documentation with DocC (in Xcode or by using `swift package generate-documentation`) and check out the Getting Started article to get set up. 24 | 25 | ## Add To Your Project 26 | 27 | To add this package to your project, use the following URL in Xcode, by picking File > Add Package Dependencies… 28 | 29 | > https://github.com/noeticgarden/experimentsboard 30 | 31 | Or, add it to your package as a dependency as follows: 32 | 33 | ```swift 34 | … 35 | // Package.swift 36 | let package = Package( 37 | … 38 | dependencies: [ 39 | … 40 | .package(url: "https://github.com/noeticgarden/experimentsboard", from: "0.1.0"), 41 | ], 42 | … 43 | targets: [ 44 | .target(… 45 | dependencies: [ 46 | .product("ExperimentsBoard", package: "experimentsboard") 47 | ]) 48 | ] 49 | ) 50 | ``` 51 | 52 | ## License 53 | 54 | The contents of this module are licensed under the MIT license except for: 55 | 56 | - Sources/ExperimentsBoard/Core/Foundation - LockedState.swift, which is licensed under the Apache License v2.0 with Runtime Library Exception. See for more information. 57 | 58 | (I would've used Synchronization's `Mutex`, but I love that this module can backdeploy now.) 59 | 60 | ## Contributions 61 | 62 | Use GitHub to [report issues](https://github.com/noeticgarden/experimentsboard/issues) or propose [pull requests](https://github.com/noeticgarden/experimentsboard/pulls). 63 | 64 | This package comes with no guarantee that any specific contribution will be included or visibly reviewed, but all issues and pull requests are at least considered. Also, please, be kind; these spaces will be actively moderated at the author's sole discretion. 65 | 66 | ### 1.0 Acceptance Criteria 67 | 68 | These builds are tagged with 0.x (pre-stability) [semantic version numbers](https://semver.org/). The goal for 1.0 is: 69 | 70 | - Consider, or defer, back-porting the editor UI to Apple OS versions prior to Fall 2023. 71 | - Consider other UI toolkits, especially e.g. Unity. 72 | - Add a gamut of other useful experiment types besides those for integer, floating-point and string values; 73 | - Performance is understood and measured; 74 | - Provide a compile-time way to disable experiments without removing calls to `Experiments.value(…)` from the source code. 75 | 76 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/Core/Foundation - LockedState.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2022 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors 10 | // 11 | //===----------------------------------------------------------------------===// 12 | 13 | // ----- 14 | // This source file was edited from its original source as part of ExperimentsBoard. 15 | // See a current copy of the original file at: 16 | // https://github.com/swiftlang/swift-foundation/blob/main/Sources/FoundationEssentials/LockedState.swift 17 | // ----- 18 | 19 | #if canImport(os) 20 | import os 21 | #elseif canImport(Bionic) 22 | import Bionic 23 | #elseif canImport(Glibc) 24 | import Glibc 25 | #elseif canImport(Musl) 26 | import Musl 27 | #elseif canImport(WinSDK) 28 | import WinSDK 29 | #endif 30 | 31 | struct LockedState { 32 | 33 | // Internal implementation for a cheap lock to aid sharing code across platforms 34 | private struct _Lock { 35 | #if canImport(os) 36 | typealias Primitive = os_unfair_lock 37 | #elseif canImport(Bionic) || canImport(Glibc) || canImport(Musl) 38 | typealias Primitive = pthread_mutex_t 39 | #elseif canImport(WinSDK) 40 | typealias Primitive = SRWLOCK 41 | #elseif os(WASI) 42 | // WASI is single-threaded, so we don't need a lock. 43 | typealias Primitive = Never 44 | #endif 45 | 46 | typealias PlatformLock = UnsafeMutablePointer 47 | var _platformLock: PlatformLock 48 | 49 | fileprivate static func initialize(_ platformLock: PlatformLock) { 50 | #if canImport(os) 51 | platformLock.initialize(to: os_unfair_lock()) 52 | #elseif canImport(Bionic) || canImport(Glibc) 53 | pthread_mutex_init(platformLock, nil) 54 | #elseif canImport(WinSDK) 55 | InitializeSRWLock(platformLock) 56 | #elseif os(WASI) 57 | // no-op 58 | #endif 59 | } 60 | 61 | fileprivate static func deinitialize(_ platformLock: PlatformLock) { 62 | #if canImport(Bionic) || canImport(Glibc) 63 | pthread_mutex_destroy(platformLock) 64 | #endif 65 | platformLock.deinitialize(count: 1) 66 | } 67 | 68 | static fileprivate func lock(_ platformLock: PlatformLock) { 69 | #if canImport(os) 70 | os_unfair_lock_lock(platformLock) 71 | #elseif canImport(Bionic) || canImport(Glibc) 72 | pthread_mutex_lock(platformLock) 73 | #elseif canImport(WinSDK) 74 | AcquireSRWLockExclusive(platformLock) 75 | #elseif os(WASI) 76 | // no-op 77 | #endif 78 | } 79 | 80 | static fileprivate func unlock(_ platformLock: PlatformLock) { 81 | #if canImport(os) 82 | os_unfair_lock_unlock(platformLock) 83 | #elseif canImport(Bionic) || canImport(Glibc) 84 | pthread_mutex_unlock(platformLock) 85 | #elseif canImport(WinSDK) 86 | ReleaseSRWLockExclusive(platformLock) 87 | #elseif os(WASI) 88 | // no-op 89 | #endif 90 | } 91 | } 92 | 93 | private class _Buffer: ManagedBuffer { 94 | deinit { 95 | withUnsafeMutablePointerToElements { 96 | _Lock.deinitialize($0) 97 | } 98 | } 99 | } 100 | 101 | private let _buffer: ManagedBuffer 102 | 103 | init(initialState: State) { 104 | _buffer = _Buffer.create(minimumCapacity: 1, makingHeaderWith: { buf in 105 | buf.withUnsafeMutablePointerToElements { 106 | _Lock.initialize($0) 107 | } 108 | return initialState 109 | }) 110 | } 111 | 112 | func withLock(_ body: @Sendable (inout State) throws -> T) rethrows -> T { 113 | try withLockUnchecked(body) 114 | } 115 | 116 | func withLockUnchecked(_ body: (inout State) throws -> T) rethrows -> T { 117 | try _buffer.withUnsafeMutablePointers { state, lock in 118 | _Lock.lock(lock) 119 | defer { _Lock.unlock(lock) } 120 | return try body(&state.pointee) 121 | } 122 | } 123 | 124 | // Ensures the managed state outlives the locked scope. 125 | func withLockExtendingLifetimeOfState(_ body: @Sendable (inout State) throws -> T) rethrows -> T { 126 | try _buffer.withUnsafeMutablePointers { state, lock in 127 | _Lock.lock(lock) 128 | return try withExtendedLifetime(state.pointee) { 129 | defer { _Lock.unlock(lock) } 130 | return try body(&state.pointee) 131 | } 132 | } 133 | } 134 | } 135 | 136 | extension LockedState where State == Void { 137 | init() { 138 | self.init(initialState: ()) 139 | } 140 | 141 | func withLock(_ body: @Sendable () throws -> R) rethrows -> R { 142 | return try withLock { _ in 143 | try body() 144 | } 145 | } 146 | 147 | func lock() { 148 | _buffer.withUnsafeMutablePointerToElements { lock in 149 | _Lock.lock(lock) 150 | } 151 | } 152 | 153 | func unlock() { 154 | _buffer.withUnsafeMutablePointerToElements { lock in 155 | _Lock.unlock(lock) 156 | } 157 | } 158 | } 159 | 160 | extension LockedState: @unchecked Sendable where State: Sendable {} 161 | 162 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/Core/Snapshot API.swift: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | Sets up and gets the current value for all your experiments. 4 | 5 | The `Experiments` type is the main entry point for setting up experiments. 6 | 7 | Use the `value(…)` methods on this class to define which experiments you would like. The first 8 | time those methods are called, they will return whatever value is given to them, but also 9 | set up the experiment so that it can be found and edited using the associated editing UI. 10 | You can repeat these same invocations later to read the value again, and values will then be returned 11 | including any edits made via the UI. 12 | 13 | ## Static API 14 | 15 | This type can be used in two ways. The first is to call the static version of its `value(…)` methods. 16 | These methods are limited to the main actor, but allow for implicit observation of changes and 17 | are by far the simplest entry point. These are: 18 | - ``value(_:key:in:)-9of4y`` (to adjust floating-point values); 19 | - ``value(_:key:in:)-mqgn`` (to adjust integer values); 20 | - ``value(_:key:)-swift.type.method`` (to adjust strings). 21 | 22 | As an example for the static API, let's turn the following value into an adjustable experiment: 23 | 24 | ```swift 25 | let number = 42 26 | ``` 27 | 28 | This can become an experiment by wrapping the value in a call to a static ``value(_:key:in:)-mqgn`` 29 | method, and, for numbers, specifying a range of valid values: 30 | 31 | ```swift 32 | let number = Experiments.value(42, 33 | key: MyExperiments.number, 34 | in: 0...100) 35 | ``` 36 | 37 | This will have no immediate effect, but should let your experiment appear in the editing UI. 38 | If you invoke this in a context that observes changes, like a SwiftUI view's `body`, you will 39 | also get appropriate updates when the editor changes your value, and executing this again 40 | will return the edited value rather than your default. 41 | 42 | For each experiment, you will need to specify a _key_ to uniquely identify and label it in the UI. 43 | 44 | Any `Hashable` and `Sendable` type will do for the key; you can use the ``ExperimentKey`` alias to 45 | quickly define one as an enumeration, but you can also just pass in a string or another 46 | existing type. Using a custom type lets editing UIs group experiments by the type of their key, which 47 | helps you find related experiments quickly. 48 | 49 | A sample definition for the key above may be: 50 | 51 | ```swift 52 | enum MyExperiments: ExperimentKey { 53 | case number 54 | } 55 | ``` 56 | 57 | That is all that it takes. Setting up an experiment this way requires very few changes or bookkeeping 58 | and can let you edit and experiment very quickly. 59 | 60 | ## Instance API 61 | 62 | The static API is bound to the main actor. If you need to access and define experiments off of the main 63 | actor, or want more control on the editing or display of a set of experiments, you will need to create 64 | instances of this type and use their `value(…)` methods: 65 | - ``value(_:key:in:)-41f0i`` (floating-point values); 66 | - ``value(_:key:in:)-5zzrl`` (integer values); 67 | - ``value(_:key:)-swift.method`` (strings). 68 | 69 | Each instance is bound to one instance of ``Storage``, which is the thread-safe container 70 | for experiment data and the source of observation callbacks. The storage accessible as the 71 | ``Storage/default`` instance is recommended for use, but you can group and abandon 72 | experiments by making your own storage instance, if needed. 73 | 74 | To read the contents of the storage, create instances of this type. Each instance contains 75 | a snapshot of the storage's contents at time of creation. Its instance methods will return those 76 | snapshot values, but will also define experiments on the associated storage when invoked 77 | for the first time with a new key, like their static counterparts do. 78 | 79 | For example, to read the shared storage, you can create an instance like so: 80 | 81 | ```swift 82 | let snapshot = Experiments() 83 | let number = snapshot.value(42, 84 | key: MyExperiments.number, 85 | in: 0...100) 86 | ``` 87 | 88 | The first time this code runs, it will return the default value, 42, and add the experiment to 89 | the storage, so that editor UI can display and modify it. That instance will, however, continue 90 | returning 42, even if the value is edited in the meantime. When you are ready to accept edits, 91 | create a new instance again to snapshot the new values; that instance's `value(…)` 92 | invocations will then include those new edits. 93 | 94 | If you want to separate some of your experiments into their own storage, create it manually: 95 | 96 | ```swift 97 | let store = Experiments.Storage() 98 | ``` 99 | 100 | You can then read snapshots of that store specifically: 101 | 102 | ```swift 103 | let snapshot = Experiments(store) 104 | ``` 105 | 106 | Snapshots are immutable and do not set up any sort of observation. If you use snapshots, 107 | you will need to observe the storage separately. You can use ``Storage/addObserver(_:)-6yxtc`` 108 | to get callbacks from the storage directly, or use ``Storage/Observable`` to 109 | get [Observation](https://developer.apple.com/documentation/observation) callbacks (for example, to integrate with the change cycle in SwiftUI). 110 | 111 | */ 112 | public struct Experiments: Sendable { 113 | let valuesWithRawKeys: [AnyExperimentStorable: AnyExperimentStorable] 114 | let store: Experiments.Storage 115 | 116 | /// Creates a snapshot of the given storage. 117 | /// 118 | /// The snapshot will be immutable and always return values that were set 119 | /// on the storage at creation time, but invocations of its `value(…)` methods will 120 | /// set up experiments with the specified store as a side effect. 121 | public init(_ store: Experiments.Storage = .default) { 122 | self.store = store 123 | self.valuesWithRawKeys = store.raw.valuesWithRawKeys 124 | } 125 | 126 | /// Defines a floating-point experiment for the specified key, and 127 | /// returns its current value. 128 | public func value(_ value: D, key: some ExperimentKey, in range: ClosedRange) -> D { 129 | store.raw[experimentFor: key] = .init(experiment: .floatingPointRange(AnyClosedFloatingPointRange(range)), defaultValue: .init(value)) 130 | return valuesWithRawKeys[AnyExperimentStorable(key)]?.base as? D ?? value 131 | } 132 | 133 | /// Defines an integer experiment for the specified key, and 134 | /// returns its current value. 135 | public func value(_ value: D, key: some ExperimentKey, in range: ClosedRange) -> D { 136 | store.raw[experimentFor: key] = .init(experiment: .integerRange(AnyClosedIntegerRange(range)), defaultValue: .init(value)) 137 | return valuesWithRawKeys[AnyExperimentStorable(key)]?.base as? D ?? value 138 | } 139 | 140 | /// Defines a string experiment for the specified key, and 141 | /// returns its current value. 142 | public func value(_ value: String, key: some ExperimentKey) -> String { 143 | store.raw[experimentFor: key] = .init(experiment: .string, defaultValue: .init(value)) 144 | return valuesWithRawKeys[AnyExperimentStorable(key)]?.base as? String ?? value 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/Core/Static API - Wrapper.swift: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | A property wrapper that can be used to quickly switch turn a variable into an experiment. 4 | 5 | Using this property wrapper is equivalent to calling the static methods of the ``Experiments`` type, but it will update 6 | a variable that you can refer to multiple times in code, or use as part of the declaration of a view. 7 | 8 | To use, declare a variable and specify the key in the annotation. The value you use to initialize the variable 9 | will be the default value of the experiment. For example: 10 | 11 | ```swift 12 | @Experimentable(MyExperiments.title) 13 | var title = "Hello!" 14 | ``` 15 | 16 | Numeric values require a valid range, passed as the `in` argument of the annotation. 17 | 18 | ```swift 19 | @Experimentable(MyExperiments.width, in: 50...900) 20 | var width = 200.0 21 | ``` 22 | 23 | You can also specify a custom ``Experiments/Observable`` to use a different store 24 | than the ``Experiments/Storage/default`` instance. 25 | 26 | ```swift 27 | @Experimentable(MyExperiments.title, observing: myExperiments) 28 | var title = "Hello!" 29 | ``` 30 | 31 | ## Concurrency 32 | 33 | This wrapper's value is isolated to the main actor if you use the default experiments storage, the same one used by the static methods in ``Experiments``. If you observe a custom storage, this variable will be bound to the same isolation as the ``Experiments/Observable`` you pass. 34 | 35 | ## Platform Support 36 | 37 | This type is available on all platforms. On platforms that support the [Observation](https://developer.apple.com/documentation/observation) module, using this type will cause changes to be tracked. This will update SwiftUI `View`s that use this variable in their `body`, for example. 38 | 39 | Tracking a store other than the default one with this type requires a platform that supports the Observation module. 40 | 41 | */ 42 | @propertyWrapper 43 | @available(macOS 14, *) 44 | @available(iOS 17, *) 45 | @available(tvOS 17, *) 46 | @available(watchOS 10, *) 47 | @available(visionOS 1, *) 48 | public struct Experimentable { 49 | let _accessor: () -> Value 50 | 51 | /// Returns the current value of this experiment. 52 | public var wrappedValue: Value { 53 | _accessor() 54 | } 55 | 56 | init(from existing: Experimentable) { 57 | self = existing 58 | } 59 | 60 | /// Defines a variable that takes the value from a string experiment in the default storage. 61 | /// 62 | /// This initializer is invoked when you define a variable like this: 63 | /// 64 | /// ```swift 65 | /// @Experimented(MyExperiments.title) 66 | /// var title = "Default Title" 67 | /// ``` 68 | @MainActor 69 | public init(wrappedValue: Value, _ key: some ExperimentKey) where Value == String { 70 | _accessor = { 71 | Experiments.observable.value(wrappedValue, key: key) 72 | } 73 | } 74 | 75 | #if canImport(Observation) && !EXPERIMENT_BOARD_DO_NOT_USE_OBSERVATION 76 | /// Defines a variable that takes the value from a string experiment in the storage observed by the specified ``Experiments/Observable``. 77 | /// 78 | /// This initializer is invoked when you define a variable like this: 79 | /// 80 | /// ```swift 81 | /// @Experimented(MyExperiments.title, observing: observable) 82 | /// var title = "Default Title" 83 | /// ``` 84 | public init(wrappedValue: Value, _ key: some ExperimentKey, observing observable: Experiments.Observable) where Value == String { 85 | _accessor = { 86 | observable.snapshot.value(wrappedValue, key: key) 87 | } 88 | } 89 | #endif 90 | 91 | /// Defines a variable that takes the value from an integer experiment in the default storage. 92 | /// 93 | /// This initializer is invoked when you define a variable like this: 94 | /// 95 | /// ```swift 96 | /// @Experimented(MyExperiments.iterations, in: 50...1000) 97 | /// var iterations = 200 98 | /// ``` 99 | @MainActor 100 | public init(wrappedValue: Value, _ key: some ExperimentKey, in range: ClosedRange) where Value: BinaryFloatingPoint & Sendable { 101 | _accessor = { 102 | Experiments.observable.value(wrappedValue, key: key, in: range) 103 | } 104 | } 105 | 106 | #if canImport(Observation) && !EXPERIMENT_BOARD_DO_NOT_USE_OBSERVATION 107 | /// Defines a variable that takes the value from an integer experiment in the storage observed by the specified ``Experiments/Observable``. 108 | /// 109 | /// This initializer is invoked when you define a variable like this: 110 | /// 111 | /// ```swift 112 | /// @Experimented(MyExperiments.iterations, in: 50...1000, 113 | /// observing: observable) 114 | /// var iterations = 200 115 | /// ``` 116 | public init(wrappedValue: Value, _ key: some ExperimentKey, in range: ClosedRange, observing observable: Experiments.Observable) where Value: BinaryFloatingPoint & Sendable { 117 | _accessor = { 118 | observable.snapshot.value(wrappedValue, key: key, in: range) 119 | } 120 | } 121 | #endif 122 | 123 | /// Defines a variable that takes the value from a floating-point value experiment in the default storage. 124 | /// 125 | /// This initializer is invoked when you define a variable like this: 126 | /// 127 | /// ```swift 128 | /// @Experimented(MyExperiments.precision, in: 0.0...1.0) 129 | /// var precision = 0.8 130 | /// ``` 131 | @MainActor 132 | public init(wrappedValue: Value, _ key: some ExperimentKey, in range: ClosedRange) where Value: BinaryInteger & Sendable { 133 | _accessor = { 134 | Experiments.observable.value(wrappedValue, key: key, in: range) 135 | } 136 | } 137 | 138 | #if canImport(Observation) && !EXPERIMENT_BOARD_DO_NOT_USE_OBSERVATION 139 | /// Defines a variable that takes the value from a floating-point value experiment in the storage observed by the specified ``Experiments/Observable``. 140 | /// 141 | /// This initializer is invoked when you define a variable like this: 142 | /// 143 | /// ```swift 144 | /// @Experimented(MyExperiments.precision, in: 0.0...1.0, 145 | /// observing: observable) 146 | /// var precision = 0.8 147 | /// ``` 148 | public init(wrappedValue: Value, _ key: some ExperimentKey, in range: ClosedRange, observing observable: Experiments.Observable) where Value: BinaryInteger & Sendable { 149 | _accessor = { 150 | observable.snapshot.value(wrappedValue, key: key, in: range) 151 | } 152 | } 153 | #endif 154 | } 155 | 156 | @available(*, unavailable) 157 | extension Experimentable: Sendable {} 158 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/Core/Static API.swift: -------------------------------------------------------------------------------- 1 | 2 | extension Experiments { 3 | @MainActor 4 | static var observable: Experiments { 5 | #if canImport(Observation) && !EXPERIMENT_BOARD_DO_NOT_USE_OBSERVATION 6 | if #available(macOS 14, 7 | iOS 17, 8 | tvOS 17, 9 | watchOS 10, 10 | visionOS 1, *) { 11 | Experiments.Observable.default.snapshot 12 | } else { 13 | Experiments() 14 | } 15 | #else 16 | Experiments() 17 | #endif 18 | } 19 | 20 | /// Defines a floating-point experiment for the specified key, 21 | /// registering it with the default experiments store, and 22 | /// returns its current value. 23 | /// 24 | /// This method is observable through the change tracking in 25 | /// the [Observation](https://developer.apple.com/documentation/observation) module. 26 | @MainActor 27 | public static func value(_ value: D, key: some ExperimentKey, in range: ClosedRange) -> D { 28 | observable.value(value, key: key, in: range) 29 | } 30 | 31 | /// Defines an integer experiment for the specified key, 32 | /// registering it with the default experiments store, and 33 | /// returns its current value. 34 | /// 35 | /// This method is observable through the change tracking in 36 | /// the [Observation](https://developer.apple.com/documentation/observation) module. 37 | @MainActor 38 | public static func value(_ value: D, key: some ExperimentKey, in range: ClosedRange) -> D { 39 | observable.value(value, key: key, in: range) 40 | } 41 | 42 | /// Defines a string experiment for the specified key, 43 | /// registering it with the default experiments store, and 44 | /// returns its current value. 45 | /// 46 | /// This method is observable through the change tracking in 47 | /// the [Observation](https://developer.apple.com/documentation/observation) module. 48 | @MainActor 49 | public static func value(_ value: String, key: some ExperimentKey) -> String { 50 | observable.value(value, key: key) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/Core/Storage - Experiment Kinds.swift: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | Determines the type and options of an experiment, as provided by the call to ``Experiments`` that created it. 4 | */ 5 | public enum ExperimentKind: Sendable, Hashable { 6 | /// Indicates an experiment that varies a floating-point value in the specified range. 7 | case floatingPointRange(AnyClosedFloatingPointRange) 8 | 9 | /// Indicates an experiment that varies an integer value in the specified range. 10 | case integerRange(AnyClosedIntegerRange) 11 | 12 | /// Indicates an experiment that varies a string. 13 | case string 14 | 15 | /// Indicates an experiment that varies an integer value in the specified range. 16 | /// 17 | /// This constructor will wrap the indicated range in a type-erasing ``AnyClosedIntegerRange`` wrapper. 18 | public static func integerRange(_ range: ClosedRange) -> Self { 19 | .integerRange(AnyClosedIntegerRange(range)) 20 | } 21 | 22 | /// Indicates an experiment that varies a floating-point value in the specified range. 23 | /// 24 | /// This constructor will wrap the indicated range in a type-erasing ``AnyClosedFloatingPointRange`` wrapper. 25 | public static func floatingPointRange(_ range: ClosedRange) -> Self { 26 | .floatingPointRange(AnyClosedFloatingPointRange(range)) 27 | } 28 | } 29 | 30 | /// A type-erased version of a `ClosedRange` over a floating-point type. 31 | public struct AnyClosedFloatingPointRange: Sendable, Hashable { 32 | public static func == (lhs: AnyClosedFloatingPointRange, rhs: AnyClosedFloatingPointRange) -> Bool { 33 | lhs.core == rhs.core 34 | } 35 | 36 | public func hash(into hasher: inout Hasher) { 37 | hasher.combine(core) 38 | } 39 | 40 | /// The type-erased value itself. 41 | public let core: AnyExperimentStorable 42 | /// The type of the range, captured at creation time. 43 | public let type: Any.Type 44 | 45 | /// Creates a type-erased version of the specified range. 46 | public init(_ range: ClosedRange) { 47 | self.core = AnyExperimentStorable(range) 48 | self.type = X.self 49 | } 50 | } 51 | 52 | /// A type-erased version of a `ClosedRange` over an integer type. 53 | public struct AnyClosedIntegerRange: Sendable, Hashable { 54 | public static func == (lhs: AnyClosedIntegerRange, rhs: AnyClosedIntegerRange) -> Bool { 55 | lhs.core == rhs.core 56 | } 57 | 58 | public func hash(into hasher: inout Hasher) { 59 | hasher.combine(core) 60 | } 61 | 62 | /// The type-erased value itself. 63 | public let core: AnyExperimentStorable 64 | /// The type of the range, captured at creation time. 65 | public let type: Any.Type 66 | 67 | /// Creates a type-erased version of the specified range. 68 | public init(_ range: ClosedRange) { 69 | self.core = AnyExperimentStorable(range) 70 | self.type = X.Type.self 71 | } 72 | } 73 | 74 | extension ClosedRange { 75 | /// Unwraps a type-erased range into a fully known `ClosedRange`. 76 | /// 77 | /// If the type of the `Bound` of the wrapped range does not correspond to what you indicated at construction time, returns `nil`. 78 | public init?(_ range: AnyClosedFloatingPointRange) where Bound: BinaryFloatingPoint { 79 | if let core = AnyHashable(range.core) as? Self { 80 | self = core 81 | } else { 82 | return nil 83 | } 84 | } 85 | 86 | /// Unwraps a type-erased range into a fully known `ClosedRange`. 87 | /// 88 | /// If the type of the `Bound` of the wrapped range does not correspond to what you indicated at construction time, returns `nil`. 89 | public init?(_ range: AnyClosedIntegerRange) where Bound: BinaryInteger { 90 | if let core = AnyHashable(range.core) as? Self { 91 | self = core 92 | } else { 93 | return nil 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/Core/Storage - Observable.swift: -------------------------------------------------------------------------------- 1 | 2 | /// Defines the requirements for an experiment key. 3 | /// 4 | /// Conform your type to this alias to quickly set up new keys for your experiments. 5 | /// For example: 6 | /// 7 | /// ```swift 8 | /// enum MyExperiments: ExperimentKey { … } 9 | /// ``` 10 | public typealias ExperimentKey = Hashable & Sendable 11 | 12 | #if canImport(Observation) && !EXPERIMENT_BOARD_DO_NOT_USE_OBSERVATION 13 | import Observation 14 | 15 | extension Experiments.Storage { 16 | /** 17 | This type has been renamed to ``Experiments/Observable``. 18 | */ 19 | @available(*, deprecated, renamed: "Experiments.Observable") 20 | @available(macOS 14, *) 21 | @available(iOS 17, *) 22 | @available(tvOS 17, *) 23 | @available(watchOS 10, *) 24 | @available(visionOS 1, *) 25 | public typealias Observable = Experiments.Observable 26 | } 27 | 28 | // Ugh, I know, I know. 29 | fileprivate struct _UnsafeSendable { 30 | nonisolated(unsafe) let value: Value 31 | } 32 | 33 | fileprivate class _UnsafeWeakSendable: @unchecked Sendable { 34 | nonisolated(unsafe) private(set) weak var value: Value? 35 | init(value: Value) { 36 | self.value = value 37 | } 38 | } 39 | 40 | extension Experiments { 41 | /** 42 | Allows code that uses the Observation module, like SwiftUI, to observe changes in experiment storage. 43 | 44 | Each instance of this class is associated with one ``Experiments/Storage`` and will produce 45 | change callbacks that can be tracked by [`withObservationTracking(_:onChange:)`](https://developer.apple.com/documentation/observation/withobservationtracking(_:onchange:)) whenever any experiment definition or value changes in the storage instance. 46 | 47 | To ensure updates occur, access experiment information within that block (or within a similarly tracked context, like a SwiftUI `View`'s `body`) by using the ``snapshot`` or ``states`` properties. 48 | 49 | Using the static methods of the ``Experiments`` type will also similarly trigger observation callbacks for the ``Experiments/Storage/default`` storage instance. You only need to create instances of this class if you want experiment state information, on top of experiment values; or if you want to follow the state of a different storage instance. 50 | 51 | ## Concurrency 52 | 53 | Instances of this class are not `Sendable` and remain isolated to one isolation context at a time. 54 | 55 | If you want to use an observer that is isolated to an actor, use the ``init(_:at:)`` initializer. Observation callbacks will be sent isolated to that actor. 56 | 57 | > Note: Before Swift 6, use the ``init(_:)`` constructor for the main actor, and the ``init(_:isolation:)`` constructor for other custom actors. While using Swift 6, you can use the ``init(_:at:)`` constructor anywhere, and it will capture the correct actor isolation automatically. 58 | 59 | If you want to isolate callbacks to a custom isolation context, use the ``init(_:executor:)`` callback. The closure you provide must eventually call the ``refresh()`` method in the context of your choice, and the change callbacks will be emitted isolated to that specific context. 60 | 61 | For example: 62 | 63 | ```swift 64 | actor MyActor { 65 | var observable: Observable? 66 | 67 | init(store: Experiments.Storage) { 68 | observable = 69 | Experiments.Observable(store) 70 | { [weak self] in 71 | await self?.refresh() 72 | } 73 | } 74 | 75 | private func refresh() { 76 | observable?.refresh() 77 | } 78 | } 79 | ``` 80 | 81 | Since ``Observable`` isn't `Sendable`, the call to ``refresh()`` ensures that the change callbacks are occurring in the isolation context that owns the ``Observable`` instance. 82 | 83 | */ 84 | @available(macOS 14, *) 85 | @available(iOS 17, *) 86 | @available(tvOS 17, *) 87 | @available(watchOS 10, *) 88 | @available(visionOS 1, *) 89 | @Observable 90 | public final class Observable { 91 | @MainActor 92 | static let `default` = Observable() 93 | 94 | final actor Observer: WeakExperimentsObserver { 95 | weak var owner: Observable? 96 | fileprivate init(owner: _UnsafeSendable) { 97 | self.owner = owner.value 98 | } 99 | 100 | func refresh() async { 101 | await owner?.executor?() 102 | } 103 | 104 | nonisolated func experimentValuesDidChange() async { 105 | await refresh() 106 | } 107 | 108 | nonisolated func experimentDefinitionsDidChange() async { 109 | await refresh() 110 | } 111 | } 112 | 113 | public struct ExperimentState: Sendable, Identifiable { 114 | public var id: AnyExperimentStorable { key } 115 | public var key: AnyExperimentStorable 116 | public var experiment: ExperimentKind 117 | public var value: (any Sendable & Hashable)? 118 | 119 | public init(key: some ExperimentKey, experiment: ExperimentKind, value: (any Sendable & Hashable)?) { 120 | self.key = AnyExperimentStorable(key) 121 | self.experiment = experiment 122 | self.value = value 123 | } 124 | 125 | public static func all(from store: Experiments.Storage) -> [Self] { 126 | let values = store.raw.valuesWithRawKeys 127 | let experiments = store.raw.experimentsWithRawKeys 128 | 129 | return experiments.compactMap { key, definition -> ExperimentState? in 130 | return ExperimentState(key: key, experiment: definition.experiment, value: values[key]?.base ?? definition.defaultValue.base) 131 | } 132 | } 133 | } 134 | 135 | /// Provides the state of experiment values on the associated storage instance, and allows registering new experiments. 136 | /// 137 | /// This property will change and emit Observable change notifications as the storage changes. 138 | private(set) public var snapshot: Experiments 139 | 140 | /// Provides the current state and definitions of all registered experiments on the associated storage instance. 141 | /// 142 | /// This property will change and emit Observable change notifications as the storage changes. 143 | private(set) public var states: [ExperimentState] = [] 144 | 145 | private let store: Experiments.Storage 146 | @ObservationIgnored private var observer: Observer? 147 | @ObservationIgnored private var executor: (@Sendable () async -> Void)? 148 | 149 | static fileprivate func refreshIsolated(_ sender: _UnsafeWeakSendable, isolation: isolated any Actor) { 150 | isolation.assertIsolated() 151 | sender.value?.refresh() 152 | } 153 | 154 | #if compiler(>=6) 155 | /// Creates a new ``Experiments/Observable`` that updates on the current actor as the specified storage instance's content changes. 156 | /// 157 | /// This method requires Swift 6 or later. 158 | /// 159 | /// > Important: Invoking this method outside an isolated context will abort your process. Use it only from an isolated context, like a `@MainActor` type or within an actor. 160 | /// 161 | /// - Parameter store: The storage to observe. 162 | /// - Parameter isolation: The actor to isolate observation callbacks to. This parameter is filled for you automatically. 163 | @_disfavoredOverload 164 | public init(_ store: Experiments.Storage = .default, at isolation: isolated (any Actor)? = #isolation) { 165 | guard let isolation else { 166 | preconditionFailure() 167 | } 168 | 169 | self.store = store 170 | self.states = ExperimentState.all(from: store) 171 | self.snapshot = Experiments(store) 172 | 173 | let smuggled = _UnsafeWeakSendable(value: self) 174 | self.executor = { 175 | await Self.refreshIsolated(smuggled, isolation: isolation) 176 | } 177 | 178 | let observer = Observer(owner: .init(value: self)) 179 | store.addObserver(observer) 180 | self.observer = observer 181 | } 182 | #endif 183 | 184 | /// Creates a new ``Experiments/Observable`` that updates on the main actor as the specified storage instance's content changes. 185 | /// 186 | /// - Parameter store: The storage to observe. 187 | @MainActor 188 | public init(_ store: Experiments.Storage = .default) { 189 | self.store = store 190 | self.states = ExperimentState.all(from: store) 191 | self.snapshot = Experiments(store) 192 | 193 | let smuggled = _UnsafeWeakSendable(value: self) 194 | self.executor = { 195 | await Self.refreshIsolated(smuggled, isolation: MainActor.shared) 196 | } 197 | 198 | let observer = Observer(owner: .init(value: self)) 199 | store.addObserver(observer) 200 | self.observer = observer 201 | } 202 | 203 | /// Creates a new ``Experiments/Observable`` that updates on the specified actor as the specified storage instance's content changes. 204 | /// 205 | /// - Parameter store: The storage to observe. 206 | /// - Parameter isolation: The actor to isolate observation callbacks to. This parameter is filled for you automatically. 207 | public init(_ store: Experiments.Storage = .default, isolation: isolated any Actor) { 208 | self.store = store 209 | self.states = ExperimentState.all(from: store) 210 | self.snapshot = Experiments(store) 211 | 212 | let smuggled = _UnsafeWeakSendable(value: self) 213 | self.executor = { 214 | await Self.refreshIsolated(smuggled, isolation: isolation) 215 | } 216 | 217 | let observer = Observer(owner: .init(value: self)) 218 | store.addObserver(observer) 219 | self.observer = observer 220 | } 221 | 222 | /// Creates a new ``Experiments/Observable`` that updates by invoking the specified executor as the specified storage instance's content changes. 223 | /// 224 | /// Use this method to invoke observable callbacks to a specific isolation context; for example, if you're using an `actor`, pass a closure that invokes `notification` on the actor's executor. 225 | /// 226 | /// To use this method, pass a closure that calls ``refresh()`` on this instance. See the concurrency discussion in the ``Observable`` documentation for more information. 227 | /// 228 | /// > Important: A new implementation that performs isolation for you is now available by invoking ``init(_:isolation:)``. Unless you want to customize how callbacks are delivered, you can use that constructor instead without supplying a closure: 229 | /// > 230 | /// > ```swift 231 | /// > let observable = Observable(store) 232 | /// >``` 233 | /// 234 | /// - Parameter store: The storage to observe. 235 | /// - Parameter executor: A block that will be invoked when the observable needs to refresh. It must ensure that the notification is invoked on the same isolation as this observer. 236 | public init(_ store: Experiments.Storage = .default, executor: @escaping @Sendable () async -> Void) { 237 | self.store = store 238 | self.states = ExperimentState.all(from: store) 239 | self.snapshot = Experiments(store) 240 | self.executor = executor 241 | 242 | let observer = Observer(owner: .init(value: self)) 243 | store.addObserver(observer) 244 | self.observer = observer 245 | } 246 | 247 | /// Causes the observer to refresh its state from the ``Experiments/Storage`` it's associated to. 248 | /// 249 | /// If you're using ``Observable`` from the main actor, with the ``init(_:)`` constructor, this method is invoked for you automatically as the storage changes. 250 | /// 251 | /// If you're supplying your own isolation, you must call this method from the `executor` closure you pass the ``init(_:executor:)`` initializer. This ensures that the refresh only ever occurs in the isolation context you provide. 252 | public func refresh() { 253 | self.states = ExperimentState.all(from: self.store) 254 | self.snapshot = Experiments(self.store) 255 | } 256 | } 257 | } 258 | 259 | @available(*, unavailable) 260 | extension Experiments.Observable: Sendable {} 261 | #endif 262 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/Core/Storage - Weak Observation.swift: -------------------------------------------------------------------------------- 1 | 2 | /// A protocol for receiving callbacks when a ``Experiments/Storage`` instance's state changes, which automatically unregisters the observer when it is deallocated. 3 | /// 4 | /// Observers that conform to this protocol are registered with ``Experiments/Storage/addObserver(_:)-ngjv``, and will automatically be removed if the instance is deallocated. 5 | /// 6 | /// > Note: To have a storage strongly reference an observer, use the ``ExperimentsObserver`` protocol instead. 7 | public protocol WeakExperimentsObserver: ExperimentsObserver, AnyObject {} 8 | 9 | protocol _WeakExperimentsObserverBox { 10 | func get() -> (any ExperimentsObserver)? 11 | } 12 | 13 | final class WeakObserverBox: _WeakExperimentsObserverBox { 14 | weak var actualObserver: Actual? 15 | 16 | func get() -> (any ExperimentsObserver)? { 17 | actualObserver 18 | } 19 | 20 | init(actualObserver: Actual) { 21 | self.actualObserver = actualObserver 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/Core/Storage.swift: -------------------------------------------------------------------------------- 1 | 2 | /// A protocol for receiving callbacks when a ``Experiments/Storage`` instance's state changes. 3 | /// 4 | /// To get callbacks, create your own ``ExperimentsObserver`` and register it with a storage instance by invoking ``Experiments/Storage/addObserver(_:)-6yxtc``. 5 | /// 6 | /// This is useful if you're building a thread-safe or lower-level observer to experiments, such as an editor, or if 7 | /// observation (through the Observation module) is not available. 8 | /// 9 | /// If you want to observe changes to experiments on the main actor, consider using ``Experiments/Observable`` instead. 10 | /// 11 | /// > Note: Experiment observers are strongly referenced (or copied, for value types). Use ``WeakExperimentsObserver`` to weakly reference an observer. 12 | public protocol ExperimentsObserver: Identifiable, Sendable where ID: Sendable { 13 | /// Invoked when one or more experiments are added, removed or changed. 14 | /// 15 | /// Use the ``Experiments/Storage/Raw-swift.struct`` view's methods to obtain the new set of experiments. 16 | /// 17 | /// > Important: You may observe invocations to your observer during the use of ``Experiments/Storage/Raw-swift.struct`` accessors. These invocations occur after the storage's mutex is released; it is fine to access the storage in those callbacks. 18 | func experimentDefinitionsDidChange() async 19 | 20 | /// Invoked when the value of one or more experiments is changed. 21 | /// 22 | /// Use the ``Experiments/Storage/Raw-swift.struct`` view's methods to obtain the new set of values. 23 | /// 24 | /// > Important: You may observe invocations to your observer during the use of ``Experiments/Storage/Raw-swift.struct`` accessors. These invocations occur after the storage's mutex is released; it is fine to access the storage in those callbacks. 25 | func experimentValuesDidChange() async 26 | } 27 | 28 | extension ExperimentsObserver { 29 | public nonisolated func experimentValuesDidChange() async { 30 | // Do nothing. 31 | } 32 | 33 | public nonisolated func experimentDefinitionsDidChange() async { 34 | // Do nothing. 35 | } 36 | } 37 | 38 | extension ExperimentsObserver where Self: AnyObject, ID == ObjectIdentifier { 39 | /// By default, tags an object that's an observer with its object identifier as its overall ID. 40 | public var id: ObjectIdentifier { 41 | ObjectIdentifier(self) 42 | } 43 | } 44 | 45 | /** 46 | A wrapper that stores a type-erased version of an experiment's key or value. 47 | 48 | This type works similarly to [`AnyHashable`](https://developer.apple.com/documentation/swift/anyhashable/), except it guarantees the erased value is also [`Sendable`](https://developer.apple.com/documentation/swift/sendable/). It does not have `AnyHashable`'s automatic bridging behavior. 49 | 50 | Use ``Swift/AnyHashable/init(_:)`` or cast the ``base`` from this type directly to obtain the original, underlying hashable value. 51 | */ 52 | public struct AnyExperimentStorable: Hashable, Sendable { 53 | public static func == (lhs: AnyExperimentStorable, rhs: AnyExperimentStorable) -> Bool { 54 | AnyHashable(lhs.base) == AnyHashable(rhs.base) 55 | } 56 | 57 | public func hash(into hasher: inout Hasher) { 58 | hasher.combine(base) 59 | } 60 | 61 | /// The underlying, erased value. 62 | public let base: any ExperimentKey 63 | 64 | /// Creates a new instance of this type by erasing the provided value. 65 | /// 66 | /// If you pass an ``AnyExperimentStorable`` to this method, it will not be double-wrapped. 67 | public init(_ core: some ExperimentKey) { 68 | if let core = core as? AnyExperimentStorable { 69 | self.base = core.base 70 | } else { 71 | self.base = core 72 | } 73 | } 74 | } 75 | 76 | extension AnyHashable { 77 | /// Unwraps a ``AnyExperimentStorable`` to obtain the hashable value it contains. 78 | public init(_ wrapper: AnyExperimentStorable) { 79 | self = AnyHashable(wrapper.base) 80 | } 81 | } 82 | 83 | 84 | extension Experiments { 85 | /** 86 | A low-level, thread-safe storage type that coordinates editing experiments. 87 | 88 | You don't generally interact with this type unless: 89 | - you want to segregate some experiments and control their lifecycle; or 90 | - you're building a low-level observer for experiments, such as editor UI. 91 | 92 | If possible, use the ``Experiments`` type to interact with experiments instead; see its documentation 93 | for details. For example, to control the lifecycle of some experiments, see the examples in that documentation 94 | for how to build a separate store and interact with it. 95 | 96 | If that API is not sufficient, use the ``raw`` property on this type to obtain direct accessors 97 | to the storage and perform editing or lookup operations. 98 | 99 | Uses of ``Experiments`` that do not specify a store will interact with the default store, accessible via the ``default`` property. 100 | 101 | ### Raw Access & Thread-Safety 102 | 103 | Use the methods in the ``Raw`` interface to access the contents of this storage. A storage instance can contain: 104 | 105 | - Experiment definitions, accessible through ``Raw/subscript(experimentFor:)``, determine 106 | what type of experiment is conducted, what the default value is, and options such as acceptable ranges for numeric experiments. See ``ExperimentDefinition`` for more. 107 | 108 | - Value overrides, accessible through ``Raw/subscript(_:)``, cause the `value(…)` methods of 109 | the ``Experiments`` type to return different values for their experiments. 110 | 111 | You can perform single-key accesses or get a bulk copy of either dictionary using the ``Raw/values`` and ``Raw/experiments`` 112 | properties, or even of the entire state of the storage using the ``Raw/state`` property. 113 | 114 | Each access performed through these accessors is atomic, synchronous, and unrelated to other accesses 115 | any code performs on the same ``Storage``, including through different ``Raw`` interfaces. This guarantees 116 | correctness of the storage in the face of multiple actors or threads using it concurrently. 117 | 118 | > Important: Each use of an accessor in ``Raw`` is protected by a mutex. Concurrent usage is supported, 119 | but you may need to think about issues such as time-of-access-to-time-of-use, lost reads, or lost writes in 120 | your invoking code. 121 | 122 | */ 123 | public final class Storage: @unchecked Sendable { 124 | /// The default instance of storage. 125 | /// 126 | /// This instance stores experiments and values for uses of ``Experiments`` where the storage 127 | /// isn't specified. 128 | public static let `default` = Experiments.Storage() 129 | 130 | /// Creates a new instance of storage with no content. 131 | public init() {} 132 | 133 | private let state = LockedState(initialState: RequiresLock()) 134 | struct RequiresLock { 135 | var values: [AnyExperimentStorable: AnyExperimentStorable] = [:] 136 | var experiments: [AnyExperimentStorable: ExperimentDefinition] = [:] 137 | var observers: [AnyExperimentStorable: any ExperimentsObserver] = [:] 138 | var weakObservers: [AnyExperimentStorable: any _WeakExperimentsObserverBox] = [:] 139 | 140 | mutating func gatherObservers() -> [any ExperimentsObserver] { 141 | var observers = Array(observers.values) 142 | for (key, box) in weakObservers { 143 | if let observer = box.get() { 144 | observers.append(observer) 145 | } else { 146 | weakObservers[key] = nil 147 | } 148 | } 149 | 150 | return observers 151 | } 152 | } 153 | 154 | /// A low-level definition of an experiment, registered with ``Storage``. 155 | public struct ExperimentDefinition: Sendable, Equatable { 156 | public static func == (lhs: Experiments.Storage.ExperimentDefinition, rhs: Experiments.Storage.ExperimentDefinition) -> Bool { 157 | lhs.experiment == rhs.experiment && AnyHashable(lhs.defaultValue) == AnyHashable(rhs.defaultValue) 158 | } 159 | 160 | /// The type and options of the experiment. 161 | public var experiment: ExperimentKind 162 | 163 | /// The default value provided by the user for this experiment. 164 | /// 165 | /// This value is wrapped in a ``AnyExperimentStorable`` type eraser. See that type for more information. 166 | public var defaultValue: AnyExperimentStorable 167 | 168 | /// Creates a new definition using an already-wrapped default value. 169 | /// 170 | /// Use low-level API in ``Storage/Raw`` to register a new definition with storage. 171 | public init(experiment: ExperimentKind, defaultValue: AnyExperimentStorable) { 172 | self.experiment = experiment 173 | self.defaultValue = defaultValue 174 | } 175 | 176 | /// Creates a new definition by wrapping the provided default value. 177 | /// 178 | /// Use low-level API in ``Storage/Raw`` to register a new definition with storage. 179 | public init(experiment: ExperimentKind, defaultValue: some Hashable & Sendable) { 180 | self.experiment = experiment 181 | self.defaultValue = .init(defaultValue) 182 | } 183 | } 184 | 185 | /// A view onto the contents of a ``Storage`` for low-level clients. 186 | public struct Raw { 187 | let storage: Storage 188 | 189 | /// Returns or registers the current value of an experiment in its current 190 | /// type-erased wrapper. 191 | /// 192 | /// An editor can use this subscript to get or set values for experiments. 193 | /// 194 | /// This accessor returns the current value of the experiment without unwrapping it from its ``AnyExperimentStorable`` wrapper. 195 | /// Use the convenience accessors at ``subscript(_:)`` and the ``set(_:for:)`` function to read and write values 196 | /// without an intervening wrapper. 197 | /// 198 | /// > Important: Every single use of the getter or setter will acquire the storage's mutex for its duration. See the discussion in ``Storage`` for more information. 199 | public subscript(raw key: some ExperimentKey) -> AnyExperimentStorable? { 200 | get { 201 | storage.state.withLock { 202 | $0.values[AnyExperimentStorable(key)] 203 | } 204 | } 205 | nonmutating set { 206 | let observers: [any ExperimentsObserver] = storage.state.withLock { 207 | let erasedKey = AnyExperimentStorable(key) 208 | 209 | let old = $0.values[erasedKey] 210 | guard old != newValue else { 211 | return [] 212 | } 213 | 214 | $0.values[erasedKey] = newValue 215 | return $0.gatherObservers() 216 | } 217 | 218 | for observer in observers { 219 | Task { 220 | await observer.experimentValuesDidChange() 221 | } 222 | } 223 | } 224 | } 225 | 226 | /// Returns the current value of an experiment. 227 | /// 228 | /// An editor can use this subscript to get the values of experiments. 229 | /// 230 | /// Using this subscript is equivalent to invoking the ``subscript(raw:)`` getter, 231 | /// and then unwrapping the returned wrapper. 232 | /// 233 | /// Because of the need to preserve type information before erasure, there is not 234 | /// a corresponding setter to this subscript. Use the ``set(_:for:)`` function 235 | /// instead. 236 | /// 237 | /// > Important: Every single use of this getter will acquire the storage's mutex for its duration. See the discussion in ``Storage`` for more information. 238 | public subscript(_ key: some ExperimentKey) -> (any Hashable & Sendable)? { 239 | self[raw: key]?.base 240 | } 241 | 242 | /// Registers the current value of an experiment. 243 | /// 244 | /// An editor can use this subscript to set values for experiments. 245 | /// 246 | /// Using this method is equivalent to invoking the ``subscript(raw:)`` setter, 247 | /// after wrapping the value appropriately. 248 | /// 249 | /// > Note: This functions as the setter equivalent of ``subscript(_:)``, with a signature 250 | /// > that preserves type information to allow for wrapping. 251 | /// 252 | /// > Important: Every single use of this method will acquire the storage's mutex for its duration. See the discussion in ``Storage`` for more information. 253 | public func set(_ value: some Hashable & Sendable, for key: some ExperimentKey) { 254 | self[raw: key] = .init(value) 255 | } 256 | 257 | /// Returns or registers the current definition of a new experiment. 258 | /// 259 | /// An editor can use this subscript to add a new experiment. In addition, ``Experiments`` sets this for you when you invoke any of its `value(…)` methods. 260 | /// 261 | /// > Important: Every single use of the getter or setter will acquire the storage's mutex for its duration. See the discussion in ``Storage`` for more information. 262 | public subscript(experimentFor key: some ExperimentKey) -> ExperimentDefinition? { 263 | get { 264 | storage.state.withLock { 265 | $0.experiments[AnyExperimentStorable(key)] 266 | } 267 | } 268 | nonmutating set { 269 | let observers: [any ExperimentsObserver] = storage.state.withLock { 270 | let erasedKey = AnyExperimentStorable(key) 271 | 272 | let old = $0.experiments[erasedKey] 273 | guard old != newValue else { 274 | return [] 275 | } 276 | 277 | $0.experiments[erasedKey] = newValue 278 | return $0.gatherObservers() 279 | } 280 | 281 | for observer in observers { 282 | Task { 283 | await observer.experimentValuesDidChange() 284 | } 285 | } 286 | } 287 | } 288 | 289 | /// Returns the current state of experiment values in storage. 290 | /// 291 | /// This method returns the same content as ``values``, without unwrapping keys from their original ``AnyExperimentStorable`` wrappers. This means, for example, that this may not work as you expect: 292 | /// 293 | /// ```swift 294 | /// let values = storage.raw.valuesWithRawKeys 295 | /// let value = values["Wow"] // Always nil. 296 | /// ``` 297 | /// 298 | /// Instead, you need to wrap or unwrap values from ``AnyExperimentStorable``. For example: 299 | /// 300 | /// ```swift 301 | /// let values = storage.raw.valuesWithRawKeys 302 | /// let value = values[AnyExperimentStorable("Wow")] // Works as expected. 303 | /// ``` 304 | /// 305 | /// In exchange for this, the return value of this method is [`Sendable`](https://developer.apple.com/documentation/swift/sendable/), and can safely be saved or loaded across isolation boundaries. 306 | /// 307 | /// > Important: Every single use of this getter will acquire the storage's mutex for its duration. See the discussion in ``Storage`` for more information. 308 | public var valuesWithRawKeys: [AnyExperimentStorable: AnyExperimentStorable] { 309 | storage.state.withLock { 310 | $0.values 311 | } 312 | } 313 | 314 | /// Returns the current state of experiment definitions in storage. 315 | /// 316 | /// This method returns the same content as ``experiments``, without unwrapping keys from their original ``AnyExperimentStorable`` wrappers. This means, for example, that this may not work as you expect: 317 | /// 318 | /// ```swift 319 | /// let experiments = storage.raw.experimentsWithRawKeys 320 | /// let experiment = experiments["Wow"] // Always nil. 321 | /// ``` 322 | /// 323 | /// Instead, you need to wrap or unwrap values from ``AnyExperimentStorable``. For example: 324 | /// 325 | /// ```swift 326 | /// let experiments = storage.raw.valuesWithRawKeys 327 | /// let experiment = experiments[AnyExperimentStorable("Wow")] 328 | /// // Works as expected. 329 | /// ``` 330 | /// 331 | /// In exchange for this, the return value of this method is [`Sendable`](https://developer.apple.com/documentation/swift/sendable/), and can safely be saved or loaded across isolation boundaries. 332 | /// 333 | /// > Important: Every single use of this getter will acquire the storage's mutex for its duration. See the discussion in ``Storage`` for more information. 334 | public var experimentsWithRawKeys: [AnyExperimentStorable: ExperimentDefinition] { 335 | storage.state.withLock { 336 | $0.experiments 337 | } 338 | } 339 | 340 | /// Returns the current state of experiment values in storage. 341 | /// 342 | /// This contains the same values you could access via ``subscript(_:)``, but the access is atomic for the entire contents of the storage. 343 | /// 344 | /// The result value of this method isn't [`Sendable`](https://developer.apple.com/documentation/swift/sendable/), even though all values in the dictionary are guaranteed to be. If you need a version that is, invoke the ``valuesWithRawKeys`` property instead. 345 | /// 346 | /// > Important: Every single use of this getter will acquire the storage's mutex for its duration. See the discussion in ``Storage`` for more information. 347 | public var values: [AnyHashable: any Sendable & Hashable] { 348 | let values = storage.state.withLock { 349 | $0.values 350 | } 351 | 352 | return Dictionary(uniqueKeysWithValues: values.map { (key, value) in 353 | (AnyHashable(key.base), value.base) 354 | }) 355 | } 356 | 357 | /// Returns the current state of experiment definitions in storage. 358 | /// 359 | /// This contains the same values you could access via ``subscript(experimentFor:)``, but the access is atomic for the entire contents of the storage. 360 | /// 361 | /// The result value of this method isn't [`Sendable`](https://developer.apple.com/documentation/swift/sendable/), even though all values in the dictionary are guaranteed to be. If you need a version that is, invoke the ``experimentsWithRawKeys`` property instead. 362 | /// 363 | /// > Important: Every single use of this getter will acquire the storage's mutex for its duration. See the discussion in ``Storage`` for more information. 364 | public var experiments: [AnyHashable: ExperimentDefinition] { 365 | let experiments = storage.state.withLock { 366 | $0.experiments 367 | } 368 | 369 | return Dictionary(uniqueKeysWithValues: experiments.map { (key, value) in 370 | (AnyHashable(key.base), value) 371 | }) 372 | } 373 | 374 | /// A snapshot of the raw state of ``Storage``. 375 | /// 376 | /// Use this type with the ``state-swift.property`` property to read or write the contents of the entire storage 377 | /// in a single, atomic operation. 378 | public struct State: Sendable { 379 | /// A dictionary of all values set for all experiments in storage. 380 | /// 381 | /// See the discussion for ``Storage/Raw/valuesWithRawKeys`` for working with wrapped ``AnyExperimentStorable`` keys. 382 | public var values: [AnyExperimentStorable: AnyExperimentStorable] = [:] 383 | 384 | /// A dictionary of all experiment definitions for all experiments in storage. 385 | /// 386 | /// See the discussion for ``Storage/Raw/experimentsWithRawKeys`` for working with wrapped ``AnyExperimentStorable`` keys. 387 | public var experiments: [AnyExperimentStorable: ExperimentDefinition] = [:] 388 | 389 | /// Creates a new instance of state. 390 | /// 391 | /// To actually set it on storage, use this value with ``Raw/state``. 392 | public init(values: [AnyExperimentStorable: AnyExperimentStorable], experiments: [AnyExperimentStorable: ExperimentDefinition]) { 393 | self.values = values 394 | self.experiments = experiments 395 | } 396 | } 397 | 398 | /// Returns the storage's contents as a low-level snapshot. 399 | /// 400 | /// See the ``State`` type for a discussion of the state you can get or set. 401 | /// 402 | /// The state is set atomically in exclusion with all other invocations of accessors on this type. You may need to account for e.g. lost reads or lost writes if you replace state atomically, especially if you do so as a function of previous state. 403 | /// 404 | /// > Important: Every single use of this getter or setter will acquire the storage's mutex for its duration. See the discussion in ``Storage`` for more information. 405 | public var state: State { 406 | get { 407 | storage.state.withLock { 408 | .init(values: $0.values, experiments: $0.experiments) 409 | } 410 | } 411 | nonmutating set { 412 | let observers = storage.state.withLock { 413 | $0.values = newValue.values 414 | $0.experiments = newValue.experiments 415 | return $0.gatherObservers() 416 | } 417 | 418 | for observer in observers { 419 | Task { 420 | await observer.experimentDefinitionsDidChange() 421 | await observer.experimentValuesDidChange() 422 | } 423 | } 424 | } 425 | } 426 | } 427 | 428 | /// Obtains a low-level view that allows you to access the raw contents of this storage. 429 | /// 430 | /// See the ``Storage`` type's discussion for more information. 431 | /// 432 | /// Even though the ``Raw`` type is a value type, it's a view over a shared resource rather than a value in itself. All values of that type access obtained from the same storage access the same backend and lock the same mutex. Copying the value does not change this. 433 | public var raw: Raw { .init(storage: self) } 434 | 435 | /// Adds an observer that is informed of changes to this storage. 436 | /// 437 | /// If a different observer with the same ID is already registered, it will be atomically removed and replaced with this one. 438 | /// 439 | /// The observer will be strongly referenced (or copied, for value types). To weakly reference an observer, conform to ``WeakExperimentsObserver`` and invoke ``addObserver(_:)-ngjv`` to register it. 440 | /// 441 | /// > Important: Every single use of this method will acquire the storage's mutex for its duration. This means that the observer will get notifications for any changes you can guarantee are sequenced after this method returns, but there is no guarantee that concurrently executing changes may produce notifications for this observer. See the discussion in ``Storage`` for more information. 442 | /// > 443 | /// > Note that you may observe invocations to your observer during the use of ``Raw-swift.struct`` accessors. These invocations occur after the storage's mutex is released; it is fine to access the storage in those callbacks. 444 | public func addObserver(_ observer: some ExperimentsObserver) { 445 | state.withLock { 446 | $0.observers[AnyExperimentStorable(observer.id)] = observer 447 | $0.weakObservers[AnyExperimentStorable(observer.id)] = nil 448 | } 449 | } 450 | 451 | /// Adds an observer that is informed of changes to this storage, weakly referenced. 452 | /// 453 | /// If a different observer with the same ID is already registered, it will be atomically removed and replaced with this one. 454 | /// 455 | /// The observer will be referenced weakly. When it deallocates, it will automatically be removed. 456 | /// 457 | /// > Note: To strongly reference an observer, use ``ExperimentsObserver`` and invoke ``addObserver(_:)-6yxtc`` instead. 458 | /// 459 | /// > Important: Every single use of this method will acquire the storage's mutex for its duration. This means that the observer will get notifications for any changes you can guarantee are sequenced after this method returns, but there is no guarantee that concurrently executing changes may produce notifications for this observer. See the discussion in ``Storage`` for more information. 460 | /// > 461 | /// > Note that you may observe invocations to your observer during the use of ``Raw-swift.struct`` accessors. These invocations occur after the storage's mutex is released; it is fine to access the storage in those callbacks. 462 | public func addObserver(_ observer: some WeakExperimentsObserver) { 463 | state.withLock { 464 | $0.observers[AnyExperimentStorable(observer.id)] = nil 465 | $0.weakObservers[AnyExperimentStorable(observer.id)] = WeakObserverBox(actualObserver: observer) 466 | } 467 | } 468 | 469 | /// Removes an observer from the set of observers informed of changes to this storage. 470 | /// 471 | /// The ID of the value you pass in will be used to remove the observer, rather than the value itself. If no observer with the same ID is registered, this method has no effect; and if an observer is different, but registered with the same ID, that observer will be removed. 472 | /// 473 | /// > Important: Every single use of this method will acquire the storage's mutex for its duration. This means that the observer will not get notifications for any changes you can guarantee are sequenced after this method returns, but may or may not get notifications for changes executed concurrently with the removal. See the discussion in ``Storage`` for more information. 474 | public func removeObserver(_ observer: some ExperimentsObserver) { 475 | state.withLock { 476 | $0.observers[AnyExperimentStorable(observer.id)] = nil 477 | $0.weakObservers[AnyExperimentStorable(observer.id)] = nil 478 | } 479 | } 480 | 481 | /// Removes an observer from the set of observers informed of changes to this storage, given its ID. 482 | /// 483 | /// If no observer with the same ID is registered, this method has no effect. 484 | /// 485 | /// > Important: Every single use of this method will acquire the storage's mutex for its duration. This means that the observer will not get notifications for any changes you can guarantee are sequenced after this method returns, but may or may not get notifications for changes executed concurrently with the removal. See the discussion in ``Storage`` for more information. 486 | public func removeObserver(id: some Hashable & Sendable) { 487 | state.withLock { 488 | $0.observers[AnyExperimentStorable(id)] = nil 489 | } 490 | } 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``ExperimentsBoard`` 2 | 3 | Quickly experiment with values without leaving your application. 4 | 5 | The `ExperimentsBoard` module allows you to quickly add UI to turn any constant in your code into something you can tweak and experiment with at runtime. If you use SwiftUI, or another UI toolkit that integrates with the `Observation` module, adding a new experiment can be as easy as editing a single line of code. 6 | 7 | You can use the static API of the ``Experiments`` type to quickly turn any constant into an experiment. For example, changing a SwiftUI view call like this: 8 | 9 | ```swift 10 | Text("Hello, world!") 11 | ``` 12 | 13 | … to replace `world` with a call to ``Experiments/value(_:key:)-type.method``: 14 | 15 | ```swift 16 | import ExperimentsBoard 17 | … 18 | Text("Hello, \(Experiments.value("world", key: "Place"))!") 19 | ``` 20 | 21 | will yield an editor similar to the following, [once you enable the editor UI](doc:Getting-Started) in your project. 22 | You will be able to edit the value at runtime, and the change will be visible immediately: 23 | 24 | ![A Hello World window with panel showing the 'world' string as editable on iOS, and then edited to 'universe'.](ios-intro.jpg) 25 | 26 | This module provides UI to edit string and numeric constants that works on all Apple OSes. See for more information. 27 | 28 | ![A Hello World window with panel showing the 'world' string as editable on macOS, and then edited to 'universe'.](macos-intro.jpg) 29 | 30 | > Note: To see what's new in this package, check out . 31 | 32 | ## Topics 33 | 34 | - 35 | 36 | ### Using ExperimentBoard 37 | 38 | - 39 | - ``Experiments/value(_:key:)-swift.type.method`` 40 | - ``Experiments/value(_:key:in:)-mqgn`` 41 | - ``Experiments/value(_:key:in:)-9of4y`` 42 | - ``ExperimentKey`` 43 | - ``Experimentable`` 44 | - 45 | 46 | ### Displaying the Editor UI 47 | 48 | - ``ExperimentsEditorScene`` 49 | - ``SwiftUICore/View/experimentsControlOverlay(hidden:)`` 50 | - ``SwiftUICore/View/showsExperimentsSceneAutomatically()`` 51 | - ``ExperimentsEditor`` 52 | - ``SwiftUICore/EnvironmentValues/openExperimentsWindow`` 53 | - ``OpenExperimentsWindowAction`` 54 | 55 | ### Custom Display & Storage 56 | 57 | - ``Experiments`` 58 | - ``Experiments/Observable`` 59 | - ``Experiments/Storage`` 60 | - ``ExperimentsObserver`` 61 | - ``WeakExperimentsObserver`` 62 | - ``Experiments/Storage/ExperimentDefinition`` 63 | - ``ExperimentKind`` 64 | - ``Experiments/Storage/Raw-swift.struct`` 65 | - ``Experiments/Storage/Raw-swift.struct/State-swift.struct`` 66 | - ``AnyExperimentStorable`` 67 | - ``Swift/AnyHashable/init(_:)`` 68 | - ``AnyClosedIntegerRange`` 69 | - ``Swift/ClosedRange/init(_:)-8rsiz`` 70 | - ``AnyClosedFloatingPointRange`` 71 | - ``Swift/ClosedRange/init(_:)-51z07`` 72 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Getting Started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Learn to add experiments quickly to your project, and enable an appropriate UI for your use case. 4 | 5 | ## Overview 6 | 7 | Once you add a dependency to the ExperimentsBoard library in your app, adding experiments to your app with the recommended API requires two steps: 8 | 9 | 1. Set up experiments by wrapping values in your code with calls to ``Experiments``. 10 | 11 | 2. Enable an appropriate editing UI for your use case. 12 | 13 | Both changes are minimally disruptive, and you should be able to interact with your experiments in minutes. 14 | 15 | ### Setting Up Experiments 16 | 17 | Once you have set up a dependency on ExperimentsBoard, you should be able to import it in your code: 18 | 19 | ```swift 20 | import ExperimentsBoard 21 | ``` 22 | 23 | > Note: Use File > Add Package Dependencies… in Xcode to set up the dependency. 24 | 25 | Find the value that needs to be edited at runtime, and replace it with a call to the appropriate method in ``Experiments``. For example: 26 | 27 | ```swift 28 | let windowTitle = "My Cool Window" 29 | 30 | // … can be edited to: 31 | let windowTitle = Experiments.value("My Cool Window", key: "Window Title") 32 | ``` 33 | 34 | The `key` parameter is used to label and group your experiments in the editor. You can use a string, as above, but you can also use your own value by conforming it to ``ExperimentKey``. 35 | 36 | The advantage of using your own type is that experiments that use the same type of key will be grouped together. For example, these two experiments will be grouped together in the editor: 37 | 38 | ```swift 39 | enum WindowSettings: ExperimentKey { 40 | case title 41 | case subtitle 42 | } 43 | 44 | let title = Experiments.value("Title", 45 | key: WindowSettings.title) 46 | let subtitle = Experiments.value("Subtitle", 47 | key: WindowSettings.title) 48 | ``` 49 | 50 | You can set up experiments for the following types: 51 | 52 | - Strings, using ``Experiments/value(_:key:)-swift.type.method``; 53 | - Integers, using ``Experiments/value(_:key:in:)-mqgn``; 54 | - Floating-point values, using ``Experiments/value(_:key:in:)-9of4y``. 55 | 56 | For numeric experiments, you need to provide a range of valid values. For example: 57 | 58 | ```swift 59 | let width = Experiments.value(100, 60 | key: WindowSettings.width, 61 | in: 40...500) 62 | ``` 63 | 64 | Once you set up experiments, if you run your app, you should see no change in behavior. You can now enable the editor UI to begin experimenting with these values. 65 | 66 | ### Enable the Editor UI 67 | 68 | There are two ways to show the editor UI: as a sheet in your application, or as a separate window. This guide will enable both, and the appropriate ones will be selected depending to the OS you build your application for. 69 | 70 | To add the editor window, add the following two lines to the `body` of your `App`: 71 | 72 | ```swift 73 | // Import this package: 74 | import ExperimentsBoard 75 | … 76 | 77 | struct MyApp: App { 78 | … 79 | var body: some Scene { 80 | … // … your existing scenes are here… 81 | 82 | // Add this line: 83 | ExperimentsEditorScene() 84 | } 85 | } 86 | ``` 87 | 88 | To add an onscreen control to display the sheet, also use the ``SwiftUICore/View/experimentsControlOverlay(hidden:)`` modifier on an existing view: 89 | 90 | ```swift 91 | // Import this package: 92 | import ExperimentsBoard 93 | … 94 | 95 | struct ContentView: View { 96 | … 97 | var body: some Scene { 98 | … // … your existing view code… 99 | // Add this modifier: 100 | .experimentsControlOverlay() 101 | } 102 | } 103 | ``` 104 | 105 | This will show a floating button in the bottom right of your content view: 106 | 107 | ![A screenshot of the overlay button](ios-overlay.jpg) 108 | 109 | > Note: The overlay assumes a large view, such as the one you use as the root view of your scene. If the view is smaller than the window, the overlay may appear in the center of your scene and overlap your content incorrectly. 110 | > 111 | > If this happens, you can change your layout, for example by using `.frame(maxWidth: .infinity, maxHeight: .infinity)`, to extend your view to fill the window, which will place the overlay farther away from your controls. 112 | 113 | ### Using the Editor 114 | 115 | Once you have enabled the UI, you can open the Experiments panel in your app. 116 | 117 | If visible, you can tap the control you added above to open the window or sheet. On platforms that support hardware keyboard shortcuts, you can also press ^⇧X to do the same. 118 | 119 | The editor will appear as appropriate for your platform, and allow you to edit your experiments inline: 120 | 121 | ![The experiment editor on tvOS](tvos-editor.jpg) 122 | 123 | > Note: On macOS, you can select View > Show Experiments from the menu bar. On iPadOS and visionOS, you can hold down the Command (⌘) key to show the menu, then select Show Experiments. 124 | > 125 | > On macOS, the overlay control will not be visible if the command is available in the menu bar. Use the menu or the keyboard shortcut. 126 | 127 | All Apple platforms support the editor, but you may want to check out the [platform support](doc:Platform-Support) document for information on how to fine-tune the UI presentation for each platform. 128 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Platform Support.md: -------------------------------------------------------------------------------- 1 | # Platform Support 2 | 3 | Discover how to use this package across platforms, including Apple OSes, Linux, WebAssembly or Windows. 4 | 5 | ## Package Support 6 | 7 | ExperimentsBoard should build on any platform that supports building Swift packages. Its capabilities depend on the platform, and the API surface depends on what support a specific platform can bring. 8 | 9 | ## SwiftUI 10 | 11 | The editor UI in this package assumes the presence of an app using the [SwiftUI app life cycle](https://developer.apple.com/documentation/swiftui/migrating-to-the-swiftui-life-cycle). 12 | 13 | The editor UI requires SwiftUI features from Fall 2023 or later. It is supported on macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, or later. The package builds and deploys on earlier versions, but the editor UI will require `if #available(…)` checks and may not be available. 14 | 15 | ## macOS 16 | 17 | On macOS, the editor will be shown as a separate window as long as you have added a ``ExperimentsEditorScene`` to your app. Use the View > Show Experiments menu item to show the window. 18 | 19 | If you don't want a separate window, apply the ``SwiftUICore/View/experimentsControlOverlay(hidden:)`` modifier to your view without adding the scene to your application. The overlay will show, and the editor view will be shown as a sheet in your window. 20 | 21 | macOS supports hardware keyboard shortcuts. If you have added either of the above to your app, you can also press ^⇧X to show the panel. 22 | 23 | ## iPadOS & visionOS 24 | 25 | On iPadOS and visionOS, the editor will be shown in a separate window. On iPadOS, the window will open and appear in Split View or Stage Manager if they're enabled in Settings. Similar to macOS, if you don't want a separate window, use just the view modifier to present it as a sheet. 26 | 27 | If multitasking is disabled in Settings in iPadOS, the experiments window will appear as a sheet. 28 | 29 | iPadOS and visionOS support hardware keyboard shortcuts as well. If you have added either of the above to your app, you can also press ^⇧X on a connected keyboard to show the panel. This works on the keyboard you're using for Mac Virtual Display on visionOS as well. 30 | 31 | 32 | ## iOS, watchOS, and tvOS 33 | 34 | On iOS, the experiments editor will appear as a sheet above your app, displayed from the view with the ``SwiftUICore/View/experimentsControlOverlay(hidden:)`` modifier applied. 35 | 36 | If your app targets only these platforms, you can avoid adding the ``ExperimentsEditorScene`` declaration to your app. The modifier is sufficient. 37 | 38 | The editor will attempt to display controls without covering most of your app. On watchOS, there is not enough screen real estate to do so, and the experiments panel shows in full screen on that platform. If you need to further customize how the experiments show, use the ``ExperimentsEditor``, ``SwiftUICore/EnvironmentValues/openExperimentsWindow`` action, or ``SwiftUICore/View/showsExperimentsSceneAutomatically()`` modifier to choose how and when the experiments editor is displayed. 39 | 40 | ## UIKit and AppKit 41 | 42 | For an UIKit or AppKit application, you may need to manually show the editor UI in a panel, inspector or sheet. For example, you may use a NSHostingController or UIHostingViewController to wrap the SwiftUI ``ExperimentsEditor`` view and set it as the content view of a NSPanel, or as a sheet with `presentViewController:animated:`. 43 | 44 | UIKit and AppKit are not aware of changes to the underlying store. To know when changes are about to occur, wrap your view update code with the [`withObservationTracking(_:onChange:)`](https://developer.apple.com/documentation/observation/withobservationtracking(_:onchange:)) function, and schedule a UI update in its change handler. Make sure to call the `value(…)` methods of ``Experiments`` only within observation tracking. 45 | 46 | ## Cross-Platform Usage 47 | 48 | ExperimentsBoard builds on all platforms that support the Swift compiler, including Linux, WebAssembly and Windows. On those platforms, it has no dependencies other than the Swift runtime itself. 49 | 50 | If SwiftUI is not available on your platform, the package will lack the API surface associated with the editor UI. The following symbols are available and fully functional even if SwiftUI is not: 51 | 52 | - ``Experiments`` and all of its nested types. This includes: 53 | - ``Experiments/Storage`` 54 | - ``Experiments/Storage/ExperimentDefinition`` 55 | - ``Experiments/Observable``, where the Observation module is supported. 56 | - Types that support this API surface, like ``AnyExperimentStorable``, ``AnyClosedIntegerRange`` and ``AnyClosedFloatingPointRange``. 57 | 58 | If your UI toolkit supports tracking changes using the [Observation](https://developer.apple.com/documentation/observation) module, the static methods of the ``Experiments`` class will cause appropriate callbacks. You will need to build experiments editing UI yourself, or publish a package that provides it. See ``Experiments/Storage`` to get started. 59 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/ios-intro.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noeticgarden/experimentsboard/47b98a0bc0d0e644d32ad92351d06aca049727cc/Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/ios-intro.jpg -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/ios-intro@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noeticgarden/experimentsboard/47b98a0bc0d0e644d32ad92351d06aca049727cc/Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/ios-intro@2x.jpg -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/ios-overlay.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noeticgarden/experimentsboard/47b98a0bc0d0e644d32ad92351d06aca049727cc/Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/ios-overlay.jpg -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/ios-overlay@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noeticgarden/experimentsboard/47b98a0bc0d0e644d32ad92351d06aca049727cc/Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/ios-overlay@2x.jpg -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/macos-intro.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noeticgarden/experimentsboard/47b98a0bc0d0e644d32ad92351d06aca049727cc/Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/macos-intro.jpg -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/macos-intro@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noeticgarden/experimentsboard/47b98a0bc0d0e644d32ad92351d06aca049727cc/Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/macos-intro@2x.jpg -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/tvos-editor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noeticgarden/experimentsboard/47b98a0bc0d0e644d32ad92351d06aca049727cc/Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/tvos-editor.jpg -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/tvos-editor@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noeticgarden/experimentsboard/47b98a0bc0d0e644d32ad92351d06aca049727cc/Sources/ExperimentsBoard/ExperimentsBoard.docc/Resources/tvos-editor@2x.jpg -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/ExperimentsBoard.docc/What's New.md: -------------------------------------------------------------------------------- 1 | # What's New 2 | 3 | Release notes for changes to this package, by version. 4 | 5 | ## Overview 6 | 7 | This document tracks changes to each published version of ExperimentsBoard. 8 | 9 | > Important: Until version 1.0, the ExperimentsBoard API is not stable and may change release to release. When it does, it will be noted below. 10 | 11 | ### ExperimentsBoard 0.1.2 12 | 13 | The big change in this version is a full rework of the ``Experiments/Observable`` type. 14 | 15 | The rework removes one use of `@unchecked Sendable` in the codebase, allowing the compiler to prove the safety of the code. This unlocks the ability for an ``Experiments/Observable`` to be bound to any isolation, not just the main actor. It now also has a shorter name (``Experiments/Observable``). 16 | 17 | To support this change, you can now register a storage observer weakly. See the ``WeakExperimentsObserver`` type and the new ``Experiments/Storage/addObserver(_:)-ngjv`` method. Also, the observation callbacks in ``ExperimentsObserver`` are now `async`, allowing you to synchronize the callback to the actor of your choice. 18 | 19 | > Note: This release relaxes requirements, except that ``Experiments/Observable`` is now explicitly not `Sendable`. Since it was bound to the main actor prior to this release, this should not be a source-breaking change. All other new features are additive and fully source-compatible with 0.1.1. 20 | 21 | > Note: Version 0.1.2.1 allows an actor to use the ``Experiments/Observable/init(_:at:)`` initializer to capture the appropriate isolation automatically. See ``Experiments/Observable`` for details. 22 | 23 | ### ExperimentsBoard 0.1.1 24 | 25 | This version introduces the ``Experimentable`` property wrapper. You can use it instead of the static API of ``Experiments`` if replacing a variable works better for your use case, rather than working directly with the constant. 26 | 27 | An example of use: 28 | 29 | ```swift 30 | @Experimentable(MyExperiments.title) 31 | var title = "Hello!" 32 | ``` 33 | 34 | This version also conforms ``Experiments`` to `Sendable`. 35 | 36 | > Note: This version is source-compatible with 0.1.0. 37 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/UI/Autodisplay.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(SwiftUI) && !EXPERIMENT_BOARD_DO_NOT_USE_SWIFTUI 3 | import SwiftUI 4 | 5 | @available(macOS 14, *) 6 | @available(iOS 17, *) 7 | @available(tvOS 17, *) 8 | @available(watchOS 10, *) 9 | @available(visionOS 1, *) 10 | struct ShowsExperimentsSceneAutomaticallyModifier: ViewModifier { 11 | @Environment(\.openExperimentsWindow) 12 | var openExperimentsWindow 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .onAppear { 17 | openExperimentsWindow() 18 | } 19 | } 20 | } 21 | 22 | extension View { 23 | /** 24 | Causes the experiments window to open automatically when the receiver appears. 25 | 26 | To function, you must have the experiments editor scene set up by adding ``ExperimentsEditorScene`` to your [App](https://developer.apple.com/documentation/swiftui/app)'s body. 27 | 28 | This modifier will use the `onAppear` timing to present the scene, though it will attempt not to present duplicate scenes. If this isn't the root view of a scene, it may cause the scene to reopen suddenly (for example, when navigation in a stack starts or ends). 29 | 30 | To control when the window opens programmatically, see the ``SwiftUICore/EnvironmentValues/openExperimentsWindow`` action instead. 31 | */ 32 | @MainActor 33 | @available(macOS 14, *) 34 | @available(iOS 17, *) 35 | @available(tvOS 17, *) 36 | @available(watchOS 10, *) 37 | @available(visionOS 1, *) 38 | public func showsExperimentsSceneAutomatically() -> some View { 39 | modifier(ShowsExperimentsSceneAutomaticallyModifier()) 40 | } 41 | } 42 | 43 | #endif 44 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/UI/Control Overlay.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(SwiftUI) && !EXPERIMENT_BOARD_DO_NOT_USE_SWIFTUI 3 | import SwiftUI 4 | 5 | extension View { 6 | /** 7 | Displays the experiments control as an overlay on top of this view. 8 | 9 | - Parameter hidden: If true, hides the overlay, effectively removing it from the scene. If false, ensures the overlay is shown. `nil` will cause the control to show only if there isn't another visible way to show the editor already. 10 | 11 | Using this modifier allows access to the default editor UI on platforms without a connected hardware keyboard. See for more information. 12 | 13 | The experiments overlay will show a control as an overlay to the receiver, in the bottom trailing corner. If the view doesn't span the entire window or screen, this may cause it to crowd atop the view; you may want to expand a root view using `.frame(maxWidth: .infinity, maxHeight: .infinity)` to compensate for this. 14 | 15 | On macOS, iPadOS and visionOS, if you added ``ExperimentsEditorScene`` to your app and you're in an environment that supports multiple windows, the control will open a new window. Otherwise, and on all other platforms, it will show the experiments editor as a sheet presented on this view's window. 16 | 17 | Note that some windows, such as visionOS volumes, may not support sheet presentations. Make sure the control can use a separate scene for those windows. 18 | 19 | > Note: On macOS, if `hidden` is `nil` (the default), the control will be hidden if the View > Show Experiments menu item is accessible on the screen. Set `hidden` to `false` to force it to be visible regardless of platform. 20 | 21 | */ 22 | @MainActor 23 | @available(macOS 14, *) 24 | @available(iOS 17, *) 25 | @available(tvOS 17, *) 26 | @available(watchOS 10, *) 27 | @available(visionOS 1, *) 28 | public func experimentsControlOverlay(hidden: Bool? = nil) -> some View { 29 | modifier(ExperimentsControlOverlayModifier(controlVisibility: { 30 | switch hidden { 31 | case nil: 32 | .automatic 33 | 34 | case true?: 35 | .hidden 36 | 37 | case false?: 38 | .visible 39 | } 40 | }())) 41 | } 42 | } 43 | 44 | // ----- 45 | 46 | @MainActor 47 | @available(macOS 14, *) 48 | @available(iOS 17, *) 49 | @available(tvOS 17, *) 50 | @available(watchOS 10, *) 51 | @available(visionOS 1, *) 52 | enum ExperimentsControlVisibility { 53 | static var automatic: Self { 54 | #if os(macOS) 55 | ExperimentsEditorScene.isAvailable ? .hidden : .visible 56 | #else 57 | .visible 58 | #endif 59 | } 60 | 61 | case visible 62 | case hidden 63 | } 64 | 65 | @available(macOS 14, *) 66 | @available(iOS 17, *) 67 | @available(tvOS 17, *) 68 | @available(watchOS 10, *) 69 | @available(visionOS 1, *) 70 | struct ExperimentsControlOverlayModifier: ViewModifier { 71 | let controlVisibility: ExperimentsControlVisibility 72 | 73 | @Environment(\.openExperimentsWindow) 74 | var openExperimentsWindow 75 | 76 | @State var isPresentingSheet = false 77 | 78 | var editor: some View { 79 | let editor = ExperimentsEditor(store: .default) 80 | .navigationTitle("Experiments") 81 | .presentationDetents([.fraction(0.3), .medium, .large]) 82 | .presentationDragIndicator(.hidden) 83 | .modifier(PagePresentationSizingIfAvailable()) 84 | .modifier(Fall2024ModifiersIfAvailable()) 85 | #if !os(tvOS) && !os(watchOS) 86 | .toolbar { 87 | ToolbarItem(placement: .confirmationAction) { 88 | Button { 89 | self.isPresentingSheet = false 90 | } label: { 91 | Text("Done") 92 | } 93 | } 94 | } 95 | #endif 96 | 97 | #if os(macOS) 98 | return editor 99 | #else 100 | return NavigationStack { 101 | editor 102 | } 103 | #endif 104 | } 105 | 106 | func body(content: Content) -> some View { 107 | content 108 | #if os(tvOS) 109 | .fullScreenCover(isPresented: $isPresentingSheet) { 110 | GeometryReader { geometry in 111 | let editorFrame = CGSize( 112 | width: geometry.size.width * 0.4, 113 | height: geometry.size.height 114 | ) 115 | 116 | let shadeFrame = CGSize( 117 | width: geometry.size.width - editorFrame.width, 118 | height: geometry.size.height 119 | ) 120 | 121 | Color.black.opacity(0.3) 122 | .ignoresSafeArea() 123 | .frame(width: shadeFrame.width, height: shadeFrame.height) 124 | .position(x: shadeFrame.width / 2, y: shadeFrame.height / 2) 125 | 126 | editor 127 | .padding() 128 | .padding(.leading, geometry.safeAreaInsets.trailing) 129 | .background(.regularMaterial) 130 | .frame(width: editorFrame.width, height: editorFrame.height) 131 | .position(x: geometry.size.width - editorFrame.width / 2, y: geometry.size.height / 2) 132 | } 133 | } 134 | #else 135 | .sheet(isPresented: $isPresentingSheet) { 136 | editor 137 | } 138 | #endif 139 | .overlay { 140 | if controlVisibility == .visible && !ExperimentsSceneTracking.shared.isShowing { 141 | GeometryReader { geometry in 142 | let side = max(44, 143 | min(geometry.size.width * 0.15, geometry.size.height * 0.15)) 144 | 145 | ZStack { 146 | Button { 147 | if openExperimentsWindow.isAvailable { 148 | openExperimentsWindow() 149 | return 150 | } 151 | 152 | self.isPresentingSheet = true 153 | } label: { 154 | Label("Experiments", systemImage: "gear") 155 | .opacity(0.2) 156 | .frame(width: side, height: side) 157 | } 158 | #if !os(tvOS) && !os(watchOS) 159 | .keyboardShortcut("x", modifiers: [.control, .shift]) 160 | #endif 161 | #if os(macOS) 162 | .buttonStyle(.accessoryBar) 163 | #else 164 | .buttonStyle(.plain) 165 | #endif 166 | .buttonBorderShape(.roundedRectangle) 167 | #if !os(macOS) && !os(visionOS) && !os(watchOS) 168 | .hoverEffect() 169 | #endif 170 | .labelStyle(.iconOnly) 171 | .padding() 172 | } 173 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) 174 | } 175 | .frame(maxWidth: .infinity, maxHeight: .infinity) 176 | } 177 | } 178 | } 179 | } 180 | 181 | @available(macOS 14, *) 182 | @available(iOS 17, *) 183 | @available(tvOS 17, *) 184 | @available(watchOS 10, *) 185 | @available(visionOS 1, *) 186 | fileprivate struct PreviewView: View { 187 | var body: some View { 188 | Text("Hello!") 189 | .frame(maxWidth: .infinity, maxHeight: .infinity) 190 | .experimentsControlOverlay(hidden: false) 191 | } 192 | } 193 | 194 | fileprivate struct PagePresentationSizingIfAvailable: ViewModifier { 195 | func body(content: Content) -> some View { 196 | #if compiler(>=6) 197 | if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { 198 | content.presentationSizing(.page) 199 | } else { 200 | content 201 | } 202 | #else 203 | content 204 | #endif 205 | } 206 | } 207 | 208 | fileprivate struct Fall2024ModifiersIfAvailable: ViewModifier { 209 | func body(content: Content) -> some View { 210 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) { 211 | content 212 | .toolbarTitleDisplayMode(.inline) 213 | .presentationBackgroundInteraction(.enabled) 214 | } else { 215 | content 216 | } 217 | } 218 | } 219 | 220 | #if compiler(>=6) // Ensure that previews are for Xcode 16 only. 221 | #if os(visionOS) 222 | #Preview(windowStyle: .automatic) { 223 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) { 224 | PreviewView() 225 | } 226 | } 227 | #endif 228 | #Preview { 229 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) { 230 | PreviewView() 231 | } 232 | } 233 | #endif 234 | #endif 235 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/UI/Editor - Form.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(SwiftUI) && !EXPERIMENT_BOARD_DO_NOT_USE_SWIFTUI 3 | import SwiftUI 4 | 5 | @available(macOS 14, *) 6 | @available(iOS 17, *) 7 | @available(tvOS 17, *) 8 | @available(watchOS 10, *) 9 | @available(visionOS 1, *) 10 | struct ExperimentsEditorForm: View { 11 | struct Grouping: Identifiable { 12 | var id: String { .init(describing: type) } 13 | 14 | var type: Any.Type 15 | var states: [Experiments.Observable.ExperimentState] 16 | } 17 | 18 | let groupings: [Grouping] 19 | let get: (_ key: any ExperimentKey) -> any Sendable 20 | let set: (_ key: any ExperimentKey, _ newValue: any Sendable & Hashable) -> Void 21 | 22 | init(experiments: [Experiments.Observable.ExperimentState], get: @escaping (_: any ExperimentKey) -> any Sendable, set: @escaping (_ key: any ExperimentKey, _ newValue: any Sendable & Hashable) -> Void) { 23 | var groupings: [String: Grouping] = [:] 24 | for experiment in experiments { 25 | let type = type(of: experiment.key.base as Any) 26 | let key = String(describing: type) 27 | groupings[key, default: .init(type: type, states: [])] 28 | .states.append(experiment) 29 | } 30 | 31 | self.groupings = groupings.sorted(by: { $0.key < $1.key }) 32 | .map { _, value in 33 | var grouping = value 34 | grouping.states.sort { 35 | String(describing: $0.key) < String(describing: $1.key) 36 | } 37 | return grouping 38 | } 39 | 40 | self.get = get 41 | self.set = set 42 | } 43 | 44 | var body: some View { 45 | Form { 46 | ForEach(groupings) { grouping in 47 | Section(grouping.id) { 48 | ForEach(grouping.states) { state in 49 | EditorRow(experiment: state, get: get) { newValue in 50 | set(state.key, newValue) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | #if os(macOS) 57 | .formStyle(.grouped) 58 | #endif 59 | } 60 | } 61 | 62 | enum PreviewLabel: String, Hashable, Sendable { 63 | case key1 64 | case key2 65 | } 66 | 67 | #if compiler(>=6) // Ensure that previews are for Xcode 16 only. 68 | #Preview { 69 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) { 70 | ExperimentsEditorForm( 71 | experiments: [ 72 | .init(key: "A", experiment: .string, value: "lol"), 73 | .init(key: "B", experiment: .string, value: "lmao"), 74 | .init(key: 1, experiment: .integerRange(0...100), value: 10), 75 | .init(key: 2, experiment: .floatingPointRange(0.0...100.0), value: 32.5), 76 | .init(key: 3, experiment: .string, value: "Hello"), 77 | .init(key: PreviewLabel.key1, experiment: .string, value: nil), 78 | .init(key: PreviewLabel.key2, experiment: .string, value: "wow"), 79 | ], 80 | get: { _ in Optional.none }, 81 | set: { print("\($0) -> \($1)") }) 82 | } 83 | } 84 | #endif 85 | 86 | #endif 87 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/UI/Editor - Rows.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(SwiftUI) && !EXPERIMENT_BOARD_DO_NOT_USE_SWIFTUI 3 | import SwiftUI 4 | 5 | extension String { 6 | fileprivate init(experimentLabelFor key: some ExperimentKey) { 7 | var result: any Hashable = key 8 | if let key = result as? AnyExperimentStorable { 9 | result = key.base 10 | } 11 | if let key = result as? AnyHashable, 12 | let base = key.base as? any Hashable { 13 | result = base 14 | } 15 | 16 | self = .init(describing: result) 17 | } 18 | } 19 | 20 | struct TypeBox: Equatable { 21 | let type: Any.Type 22 | init(_ type: Any.Type) { 23 | self.type = type 24 | } 25 | static func == (_ lhs: Self, rhs: Self) -> Bool { 26 | lhs.type == rhs.type 27 | } 28 | } 29 | 30 | @available(macOS 14, *) 31 | @available(iOS 17, *) 32 | @available(tvOS 17, *) 33 | @available(watchOS 10, *) 34 | @available(visionOS 1, *) 35 | struct EditorRow: View { 36 | let experiment: Experiments.Observable.ExperimentState 37 | let get: (_ key: any ExperimentKey) -> any Sendable 38 | let set: (_ newValue: any Sendable & Hashable) -> Void 39 | 40 | var body: some View { 41 | switch experiment.experiment { 42 | case .floatingPointRange(let anyClosedFloatingPointRange): 43 | if let range = ClosedRange(anyClosedFloatingPointRange) { 44 | EditorRowForFloatingPoint(experiment: experiment, range: range, get: get, set: set) 45 | } else if let range = ClosedRange(anyClosedFloatingPointRange) { 46 | EditorRowForFloatingPoint(experiment: experiment, range: range, get: get, set: set) 47 | } else if let range = ClosedRange(anyClosedFloatingPointRange) { 48 | EditorRowForFloatingPoint(experiment: experiment, range: range, get: get, set: set) 49 | } else if let range = ClosedRange(anyClosedFloatingPointRange) { 50 | EditorRowForFloatingPoint(experiment: experiment, range: range, get: get, set: set) 51 | } else if let range = ClosedRange(anyClosedFloatingPointRange) { 52 | EditorRowForFloatingPoint(experiment: experiment, range: range, get: get, set: set) 53 | } 54 | case .integerRange(let anyClosedIntegerRange): 55 | if let range = ClosedRange(anyClosedIntegerRange) { 56 | EditorRowForInteger(experiment: experiment, range: range, get: get, set: set) 57 | } else if let range = ClosedRange(anyClosedIntegerRange) { 58 | EditorRowForInteger(experiment: experiment, range: range, get: get, set: set) 59 | } else if let range = ClosedRange(anyClosedIntegerRange) { 60 | EditorRowForInteger(experiment: experiment, range: range, get: get, set: set) 61 | } else if let range = ClosedRange(anyClosedIntegerRange) { 62 | EditorRowForInteger(experiment: experiment, range: range, get: get, set: set) 63 | } else if let range = ClosedRange(anyClosedIntegerRange) { 64 | EditorRowForInteger(experiment: experiment, range: range, get: get, set: set) 65 | } else { 66 | EmptyView() 67 | } 68 | case .string: 69 | EditorRowForString(experiment: experiment, get: get, set: set) 70 | } 71 | } 72 | } 73 | 74 | // By experiment kind: 75 | 76 | @available(macOS 14, *) 77 | @available(iOS 17, *) 78 | @available(tvOS 17, *) 79 | @available(watchOS 10, *) 80 | @available(visionOS 1, *) 81 | fileprivate struct EditorRowForString: View { 82 | let experiment: Experiments.Observable.ExperimentState 83 | let get: (_ key: any ExperimentKey) -> any Sendable 84 | let set: (_ newValue: any Sendable & Hashable) -> Void 85 | 86 | @State var text = "" 87 | 88 | var body: some View { 89 | let textField = TextField(String(experimentLabelFor: experiment.key), text: $text, prompt: Text("Default value")) 90 | .multilineTextAlignment(.trailing) 91 | #if !os(macOS) 92 | .textInputAutocapitalization(.never) 93 | #endif 94 | .onAppear { 95 | self.text = experiment.value as? String ?? get(experiment.key) as? String ?? "" 96 | } 97 | .onChange(of: text) { oldValue, newValue in 98 | set(newValue) 99 | } 100 | 101 | #if os(macOS) 102 | textField 103 | #else 104 | LabeledContent(String(experimentLabelFor: experiment.key)) { 105 | textField 106 | } 107 | #endif 108 | } 109 | } 110 | 111 | @available(macOS 14, *) 112 | @available(iOS 17, *) 113 | @available(tvOS 17, *) 114 | @available(watchOS 10, *) 115 | @available(visionOS 1, *) 116 | fileprivate struct EditorRowForInteger: View { 117 | let experiment: Experiments.Observable.ExperimentState 118 | let range: ClosedRange 119 | let get: (_ key: any ExperimentKey) -> any Sendable 120 | let set: (_ newValue: any Sendable & Hashable) -> Void 121 | 122 | @State var value = 0.0 123 | 124 | var body: some View { 125 | LabeledContent(String(experimentLabelFor: experiment.key)) { 126 | #if os(tvOS) 127 | HStack { 128 | Text("\(Integer(value))") 129 | .foregroundStyle(.primary) 130 | HStack(spacing: 8) { 131 | Button { 132 | value += 1.0 133 | } label: { 134 | Image(systemName: "plus") 135 | .resizable() 136 | .aspectRatio(contentMode: .fit) 137 | .frame(width: 24, height: 24) 138 | } 139 | .buttonBorderShape(.circle) 140 | .disabled(Integer(value) + 1 > range.upperBound) 141 | 142 | Button { 143 | value -= 1.0 144 | } label: { 145 | Image(systemName: "minus") 146 | .resizable() 147 | .aspectRatio(contentMode: .fit) 148 | .frame(width: 24, height: 24) 149 | } 150 | .buttonBorderShape(.circle) 151 | .disabled(Integer(value) - 1 < range.lowerBound) 152 | } 153 | .buttonStyle(.bordered) 154 | .labelStyle(.iconOnly) 155 | } 156 | #else 157 | Slider( 158 | value: $value, 159 | in: Double(range.lowerBound)...Double(range.upperBound), 160 | label: { Text("\(Integer(value))").monospacedDigit().frame(minWidth: 25) }, 161 | minimumValueLabel: { Text("\(range.lowerBound)") }, 162 | maximumValueLabel: { Text("\(range.upperBound)") } 163 | ) 164 | #endif 165 | } 166 | .onAppear { 167 | self.value = Double(experiment.value as? Integer ?? get(experiment.key) as? Integer ?? 0) 168 | } 169 | .onChange(of: value) { oldValue, newValue in 170 | set(Integer(newValue)) 171 | } 172 | } 173 | } 174 | 175 | @available(macOS 14, *) 176 | @available(iOS 17, *) 177 | @available(tvOS 17, *) 178 | @available(watchOS 10, *) 179 | @available(visionOS 1, *) 180 | fileprivate struct EditorRowForFloatingPoint: View { 181 | let experiment: Experiments.Observable.ExperimentState 182 | let range: ClosedRange 183 | let get: (_ key: any ExperimentKey) -> any Sendable 184 | let set: (_ newValue: any Sendable & Hashable) -> Void 185 | 186 | @State var value = 0.0 187 | 188 | var valueLabel: String { 189 | value.formatted(.number.precision(.fractionLength(3))) 190 | } 191 | 192 | var body: some View { 193 | LabeledContent(String(experimentLabelFor: experiment.key)) { 194 | #if os(tvOS) 195 | HStack { 196 | Text("\(FloatingPoint(value))") 197 | .foregroundStyle(.primary) 198 | HStack(spacing: 8) { 199 | Button { 200 | value += 0.3 201 | } label: { 202 | Image(systemName: "plus") 203 | .resizable() 204 | .aspectRatio(contentMode: .fit) 205 | .frame(width: 24, height: 24) 206 | } 207 | .buttonBorderShape(.circle) 208 | .disabled(FloatingPoint(value) + 0.3 > range.upperBound) 209 | 210 | Button { 211 | value -= 0.3 212 | } label: { 213 | Image(systemName: "minus") 214 | .resizable() 215 | .aspectRatio(contentMode: .fit) 216 | .frame(width: 24, height: 24) 217 | } 218 | .buttonBorderShape(.circle) 219 | .disabled(FloatingPoint(value) - 0.3 < range.lowerBound) 220 | } 221 | .buttonStyle(.bordered) 222 | .labelStyle(.iconOnly) 223 | } 224 | #else 225 | Slider( 226 | value: $value, 227 | in: Double(range.lowerBound)...Double(range.upperBound), 228 | label: { Text(valueLabel).monospacedDigit().frame(minWidth: 65) }, 229 | minimumValueLabel: { Text("\(range.lowerBound)") }, 230 | maximumValueLabel: { Text("\(range.upperBound)") } 231 | ) 232 | #endif 233 | } 234 | .onAppear { 235 | self.value = Double(experiment.value as? FloatingPoint ?? get(experiment.key) as? FloatingPoint ?? 0) 236 | } 237 | .onChange(of: value) { oldValue, newValue in 238 | set(FloatingPoint(newValue)) 239 | } 240 | } 241 | } 242 | 243 | // ----- 244 | 245 | #if compiler(>=6) // Ensure that previews are for Xcode 16 only. 246 | 247 | @available(macOS 14, *) 248 | @available(iOS 17, *) 249 | @available(tvOS 17, *) 250 | @available(watchOS 10, *) 251 | @available(visionOS 1, *) 252 | fileprivate struct _PreviewViewForString: View { 253 | @State var current: any Sendable = "Current" 254 | var body: some View { 255 | Form { 256 | EditorRow(experiment: .init( 257 | key: "testForString", 258 | experiment: .string, 259 | value: nil 260 | )) { _ in 261 | current 262 | } set: { (newValue) in 263 | current = newValue 264 | print("SET: \(newValue)") 265 | } 266 | } 267 | #if os(macOS) 268 | .formStyle(.grouped) 269 | #endif 270 | } 271 | } 272 | 273 | #Preview("Editor Row (String)") { 274 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) { 275 | _PreviewViewForString() 276 | } 277 | } 278 | 279 | @available(macOS 14, *) 280 | @available(iOS 17, *) 281 | @available(tvOS 17, *) 282 | @available(watchOS 10, *) 283 | @available(visionOS 1, *) 284 | fileprivate struct _PreviewViewForInteger: View { 285 | @State var current: any Sendable = I(12) 286 | 287 | var body: some View { 288 | Form { 289 | EditorRow(experiment: .init( 290 | key: "testFor\(I.self)", 291 | experiment: .integerRange(I(0)...I(100)), 292 | value: nil 293 | )) { _ in 294 | current 295 | } set: { (newValue) in 296 | current = newValue 297 | print("SET: \(newValue)") 298 | } 299 | } 300 | #if os(macOS) 301 | .formStyle(.grouped) 302 | #endif 303 | } 304 | } 305 | 306 | #Preview("Editor Row (Int)") { 307 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) { 308 | _PreviewViewForInteger() 309 | } 310 | } 311 | 312 | 313 | #Preview("Editor Row (Int8)") { 314 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) { 315 | _PreviewViewForInteger() 316 | } 317 | } 318 | 319 | @available(macOS 14, *) 320 | @available(iOS 17, *) 321 | @available(tvOS 17, *) 322 | @available(watchOS 10, *) 323 | @available(visionOS 1, *) 324 | fileprivate struct _PreviewViewForFloatingPoint: View { 325 | @State var current: any Sendable = I(12.0) 326 | 327 | var body: some View { 328 | Form { 329 | EditorRow(experiment: .init( 330 | key: "testFor\(I.self)", 331 | experiment: .floatingPointRange(I(0)...I(100)), 332 | value: nil 333 | )) { _ in 334 | current 335 | } set: { (newValue) in 336 | current = newValue 337 | print("SET: \(newValue)") 338 | } 339 | } 340 | #if os(macOS) 341 | .formStyle(.grouped) 342 | #endif 343 | } 344 | } 345 | 346 | #Preview("Editor Row (Double)") { 347 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) { 348 | _PreviewViewForFloatingPoint() 349 | } 350 | } 351 | 352 | #Preview("Editor Row (Float)") { 353 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) { 354 | _PreviewViewForFloatingPoint() 355 | } 356 | } 357 | #endif 358 | 359 | #endif 360 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/UI/Editor.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(SwiftUI) && !EXPERIMENT_BOARD_DO_NOT_USE_SWIFTUI 3 | import SwiftUI 4 | 5 | /** 6 | A view that shows an editor for current experiments. 7 | 8 | Add this view to your hierarchy to show an editor that allows you to edit all experiments in a specified `Experiments.Storage`. 9 | 10 | Consider using the ``ExperimentsEditorScene`` in your app first rather than embedding an editor directly. You may want to use this view if you need to edit experiments in a storage that is not the default one, or if you need to present the editing UI in a custom way within your view hierarchy. 11 | */ 12 | @available(macOS 14, *) 13 | @available(iOS 17, *) 14 | @available(tvOS 17, *) 15 | @available(watchOS 10, *) 16 | @available(visionOS 1, *) 17 | public struct ExperimentsEditor: View { 18 | let store: Experiments.Storage 19 | @State var observable: Experiments.Observable? 20 | 21 | /// Creates a new editor view that presents and edits experiments for the specified store. 22 | public init(store: Experiments.Storage = .default) { 23 | self.store = store 24 | } 25 | 26 | public var body: some View { 27 | ExperimentsEditorForm( 28 | experiments: observable?.states ?? [], 29 | get: { 30 | store.raw[$0] 31 | }, 32 | set: { key, newValue in 33 | store.raw[raw: key] = AnyExperimentStorable(newValue) 34 | } 35 | ) 36 | .onChange(of: ObjectIdentity(store), initial: true) { oldValue, newValue in 37 | self.observable = Experiments.Observable(newValue.object) 38 | } 39 | } 40 | } 41 | 42 | #if compiler(>=6) // Ensure that previews are for Xcode 16 only. 43 | #Preview { 44 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) { 45 | ExperimentsEditor(store: { 46 | let store = Experiments.Storage() 47 | let experiments = Experiments(store) 48 | 49 | _ = experiments.value("Nice", key: "Wow") 50 | _ = experiments.value(1, key: "Nice", in: 1...100) 51 | _ = experiments.value(100.0, key: "Optimal", in: -200.0...200.0) 52 | 53 | return store 54 | }()) 55 | } 56 | } 57 | #endif 58 | #endif 59 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/UI/Scene.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(SwiftUI) && !EXPERIMENT_BOARD_DO_NOT_USE_SWIFTUI 3 | import SwiftUI 4 | 5 | /** 6 | A scene that allows you to access and edit experiments at runtime. 7 | 8 | Add this scene to your application to show an experiments editor as a separate window. See to see this in action. 9 | 10 | Once added to your SwiftUI `App`, on macOS, iOS, iPadOS and visionOS, you can invoke this window by pressing ^⇧X on a connected hardware keyboard or by choosing the command this scene adds to the app's menu. 11 | 12 | > Note: You can disable adding the menu command by using the [`commandsRemoved()`](https://developer.apple.com/documentation/swiftui/scene/commandsremoved()) modifier on this scene in your `App`. This will also disable the keyboard shortcut. 13 | 14 | Not all platforms support multiple windows. Use the ``SwiftUICore/View/experimentsControlOverlay(hidden:)`` modifier in your app for those platforms. If multiple windows aren't supported, the control displayed by that modifier will open the editor view as a sheet or overlay on top of the current window instead. 15 | 16 | For more information on how to access the window, see . 17 | 18 | > Currently, this scene only supports editing the default `Experiments.Storage`, which is what the static API uses. If you need to edit experiments in your own storage instance, embed or present an ``ExperimentsEditor`` view that you initialize appropriately. 19 | 20 | */ 21 | @MainActor 22 | @available(macOS 14, *) 23 | @available(iOS 17, *) 24 | @available(tvOS 17, *) 25 | @available(watchOS 10, *) 26 | @available(visionOS 1, *) 27 | public struct ExperimentsEditorScene: Scene { 28 | static let utilityPanelID = "name.millenomi.ExperimentsBoardUI.scene.utilityPanel" 29 | static let windowGroupID = "name.millenomi.ExperimentsBoardUI.scene.windowGroup" 30 | 31 | static func disabledID() -> String { 32 | "name.millenomi.ExperimentsBoardUI.scene.disabled.\(UUID())" 33 | } 34 | 35 | static var fallbackWindowGroupIsEnabled: Bool { 36 | #if os(macOS) 37 | if #available(macOS 15, *) { 38 | return false 39 | } else { 40 | return true 41 | } 42 | #elseif os(visionOS) 43 | if #available(visionOS 2, *) { 44 | return false 45 | } else { 46 | return true 47 | } 48 | #else 49 | return true 50 | #endif 51 | } 52 | 53 | static var isAvailable = false 54 | 55 | let store: Experiments.Storage 56 | public init() { 57 | self.store = .default // TODO: Multiple board scenes for multiple storages. 58 | Self.isAvailable = true 59 | } 60 | 61 | public var body: some Scene { 62 | #if os(macOS) && compiler(>=6) // Require the macOS 15 SDK. 63 | if #available(macOS 15, *) { 64 | UtilityWindow("Experiments", id: Self.utilityPanelID) { 65 | ExperimentsEditor(store: store) 66 | .onAppear { 67 | ExperimentsSceneTracking.shared.countOfShowingScenes += 1 68 | } 69 | .onDisappear { 70 | ExperimentsSceneTracking.shared.countOfShowingScenes -= 1 71 | } 72 | } 73 | .keyboardShortcut("x", modifiers: [.control, .shift]) 74 | } 75 | #endif 76 | 77 | #if os(visionOS) && compiler(>=6) // Require the visionOS 2 SDK. 78 | if #available(visionOS 2, *) { 79 | ExperimentsBoardWindowScene(id: Self.windowGroupID, store: store, isEnabled: true) 80 | .defaultWindowPlacement { content, context in 81 | .init(.utilityPanel) 82 | } 83 | } 84 | #endif 85 | 86 | ExperimentsBoardWindowScene( 87 | id: Self.fallbackWindowGroupIsEnabled ? Self.windowGroupID : Self.disabledID(), 88 | store: store, 89 | isEnabled: Self.fallbackWindowGroupIsEnabled 90 | ) 91 | } 92 | } 93 | 94 | // ----- 95 | 96 | @Observable 97 | @MainActor 98 | @available(macOS 14, *) 99 | @available(iOS 17, *) 100 | @available(tvOS 17, *) 101 | @available(watchOS 10, *) 102 | @available(visionOS 1, *) 103 | final class ExperimentsSceneTracking { 104 | static let shared = ExperimentsSceneTracking() 105 | 106 | var countOfShowingScenes = 0 107 | 108 | var isShowing: Bool { 109 | countOfShowingScenes > 0 110 | } 111 | } 112 | 113 | @available(macOS 14, *) 114 | @available(iOS 17, *) 115 | @available(tvOS 17, *) 116 | @available(watchOS 10, *) 117 | @available(visionOS 1, *) 118 | struct ExperimentsBoardWindowScene: Scene { 119 | let id: String 120 | let store: Experiments.Storage 121 | let isEnabled: Bool 122 | 123 | var body: some Scene { 124 | WindowGroup(id: id) { 125 | if isEnabled { 126 | NavigationStack { 127 | ExperimentsEditor(store: store) 128 | .navigationTitle("Experiments") 129 | .toolbarTitleDisplayMode(.inline) 130 | } 131 | .onAppear { 132 | ExperimentsSceneTracking.shared.countOfShowingScenes += 1 133 | } 134 | .onDisappear { 135 | ExperimentsSceneTracking.shared.countOfShowingScenes -= 1 136 | } 137 | } else { 138 | EmptyView() 139 | } 140 | } 141 | #if !os(tvOS) && !os(watchOS) 142 | .defaultSize(width: 525, height: 750) 143 | .commands { 144 | if isEnabled { 145 | ExperimentsSceneCommands() 146 | } 147 | } 148 | #endif 149 | } 150 | } 151 | 152 | /** 153 | An action that allows you to open an experiments editor window, if possible. 154 | 155 | You do not construct instances of this type. Instead, obtain one from your view's [`Environment`](https://developer.apple.com/documentation/swiftui/environment), 156 | like so: 157 | 158 | ```swift 159 | @Environment(\.openExperimentsWindow) var openExperimentsWindow 160 | ``` 161 | 162 | Invoke this as a function to open the experiments window, if possible: 163 | 164 | ```swift 165 | openExperimentsWindow() 166 | ``` 167 | 168 | In order for this action to open a window, ensure you added an ``ExperimentsEditorScene`` to your `App`. 169 | 170 | You can invoke this action even on a platform that doesn't support multiple windows, or when you haven't added ``ExperimentsEditorScene`` to your `App`. In that case, it does nothing. Use the ``isAvailable`` property to check whether invoking this action may display a window. For example: 171 | 172 | ```swift 173 | // Only display the button if we can open the window: 174 | if openExperimentsWindow.isAvailable { 175 | Button("Open Window") ( … } 176 | } 177 | ``` 178 | 179 | This action only opens separate windows. To manually present the editor UI on a platform that can't open a second window, present an ``ExperimentsEditor`` view instead. 180 | */ 181 | @MainActor 182 | @available(macOS 14, *) 183 | @available(iOS 17, *) 184 | @available(tvOS 17, *) 185 | @available(watchOS 10, *) 186 | @available(visionOS 1, *) 187 | public struct OpenExperimentsWindowAction { 188 | #if !(os(tvOS) || os(watchOS)) 189 | let openWindow: OpenWindowAction 190 | let supportsMultipleWindows: Bool 191 | #endif 192 | 193 | /// Opens the experiments window, if possible. 194 | public func callAsFunction() { 195 | #if os(tvOS) || os(watchOS) 196 | return 197 | #else 198 | guard isAvailable else { 199 | return 200 | } 201 | 202 | #if os(macOS) 203 | if #available(macOS 15, *) { 204 | openWindow(id: ExperimentsEditorScene.utilityPanelID) 205 | return 206 | } 207 | #endif 208 | 209 | openWindow(id: ExperimentsEditorScene.windowGroupID) 210 | #endif 211 | } 212 | 213 | /// If `true`, invoking this action as a functon may succeed. It is `false` otherwise. 214 | public var isAvailable: Bool { 215 | #if os(tvOS) || os(watchOS) 216 | false 217 | #else 218 | supportsMultipleWindows && !ExperimentsSceneTracking.shared.isShowing && ExperimentsEditorScene.isAvailable 219 | #endif 220 | } 221 | } 222 | 223 | extension EnvironmentValues { 224 | /** 225 | An action to open the experiments editor window, if possible. 226 | 227 | Invoke this action as a function to attempt to open the experiments window if it's not visible. For example, to provide your own open-window button: 228 | 229 | ```swift 230 | struct MyView { 231 | … 232 | @Environment(\.openExperimentsWindow) 233 | var openExperimentsWindow 234 | 235 | var body: some View { 236 | … 237 | Button("Open Experiments Window") { 238 | openExperimentsWindow() 239 | } 240 | } 241 | } 242 | ``` 243 | 244 | In order for this action to work, you will need to add a ``ExperimentsEditorScene`` to your `App`. Invoking this will attempt not to open duplicate windows. 245 | For more, see ``OpenExperimentsWindowAction``. 246 | */ 247 | @MainActor 248 | @available(macOS 14, *) 249 | @available(iOS 17, *) 250 | @available(tvOS 17, *) 251 | @available(watchOS 10, *) 252 | @available(visionOS 1, *) 253 | public var openExperimentsWindow: OpenExperimentsWindowAction { 254 | #if os(tvOS) || os(watchOS) 255 | .init() 256 | #else 257 | .init(openWindow: openWindow, supportsMultipleWindows: supportsMultipleWindows) 258 | #endif 259 | } 260 | } 261 | 262 | #if !os(tvOS) && !os(watchOS) 263 | @available(macOS 14, *) 264 | @available(iOS 17, *) 265 | @available(tvOS 17, *) 266 | @available(watchOS 10, *) 267 | @available(visionOS 1, *) 268 | struct ExperimentsSceneCommands: Commands { 269 | @Environment(\.openExperimentsWindow) 270 | var openExperimentsWindow 271 | 272 | @Environment(\.supportsMultipleWindows) 273 | var supportsMultipleWindows 274 | 275 | var body: some Commands { 276 | if supportsMultipleWindows { 277 | CommandGroup(after: .toolbar) { 278 | Button("Show Experiments…") { 279 | MainActor.assumeIsolated { // Older SDKs aren't correctly annotated wrt. MainActorness here. 280 | openExperimentsWindow() 281 | } 282 | } 283 | .keyboardShortcut("x", modifiers: [.control, .shift]) 284 | } 285 | } 286 | } 287 | } 288 | #endif 289 | 290 | #endif 291 | -------------------------------------------------------------------------------- /Sources/ExperimentsBoard/UI/Tools.swift: -------------------------------------------------------------------------------- 1 | 2 | struct ObjectIdentity: Equatable, Hashable { 3 | static func == (lhs: ObjectIdentity, rhs: ObjectIdentity) -> Bool { 4 | lhs.object === rhs.object 5 | } 6 | 7 | func hash(into hasher: inout Hasher) { 8 | hasher.combine(ObjectIdentifier(object)) 9 | } 10 | 11 | let object: X 12 | init(_ object: X) { 13 | self.object = object 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/ExperimentsBoardTests/Tests - Observables.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(Observation) 3 | import Testing 4 | import Observation 5 | @testable import ExperimentsBoard 6 | 7 | @Suite 8 | struct ObservablesTests { 9 | @MainActor 10 | @Test(.timeLimit(.minutes(1))) 11 | @available(macOS 14, *) 12 | @available(iOS 17, *) 13 | @available(tvOS 17, *) 14 | @available(watchOS 10, *) 15 | @available(visionOS 1, *) 16 | func testValuesObservable() async { 17 | let store = Experiments.Storage() 18 | let observable = Experiments.Observable(store) 19 | 20 | await withCheckedContinuation { continuation in 21 | withObservationTracking { 22 | let result = observable.snapshot.value("Default Value", key: "Test") 23 | #expect(result == "Default Value") 24 | } onChange: { 25 | continuation.resume() 26 | } 27 | 28 | Task.detached { 29 | store.raw[raw: "Test"] = .init("Wow") 30 | } 31 | } 32 | } 33 | 34 | @MainActor 35 | @Test(.timeLimit(.minutes(1))) 36 | @available(macOS 14, *) 37 | @available(iOS 17, *) 38 | @available(tvOS 17, *) 39 | @available(watchOS 10, *) 40 | @available(visionOS 1, *) 41 | func testKindsObservable() async { 42 | let store = Experiments.Storage() 43 | 44 | let key = "Wow" 45 | let kind = ExperimentKind.integerRange(0...100) 46 | let value = 52 47 | store.raw.set(value, for: key) 48 | store.raw[experimentFor: key] = .init(experiment: kind, defaultValue: value) 49 | 50 | let observable = Experiments.Observable(store) 51 | 52 | await withCheckedContinuation { continuation in 53 | withObservationTracking { 54 | let states = observable.states 55 | #expect(states.count == 1) 56 | if let first = try? #require(states.first) { 57 | #expect(first.experiment == kind) 58 | #expect(first.value as? Int == value) 59 | } 60 | } onChange: { 61 | continuation.resume() 62 | } 63 | 64 | Task.detached { 65 | store.raw[experimentFor: key] = .init(experiment: .integerRange(0...200), defaultValue: value) 66 | } 67 | } 68 | } 69 | 70 | @available(macOS 14, *) 71 | @available(iOS 17, *) 72 | @available(tvOS 17, *) 73 | @available(watchOS 10, *) 74 | @available(visionOS 1, *) 75 | actor IsolatedOtherwise { 76 | var observable: Experiments.Observable? 77 | 78 | private func refresh() { 79 | observable?.refresh() 80 | } 81 | 82 | func testValuesObservable() async { 83 | let store = Experiments.Storage() 84 | let observable = Experiments.Observable(store) { [weak self] in 85 | await self?.refresh() 86 | } 87 | self.observable = observable 88 | 89 | await withCheckedContinuation { continuation in 90 | withObservationTracking { 91 | let result = observable.snapshot.value("Default Value", key: "Test") 92 | #expect(result == "Default Value") 93 | } onChange: { 94 | continuation.resume() 95 | } 96 | 97 | Task.detached { 98 | store.raw[raw: "Test"] = .init("Wow") 99 | } 100 | } 101 | } 102 | 103 | func testKindsObservable() async { 104 | let store = Experiments.Storage() 105 | 106 | let key = "Wow" 107 | let kind = ExperimentKind.integerRange(0...100) 108 | let value = 52 109 | store.raw.set(value, for: key) 110 | store.raw[experimentFor: key] = .init(experiment: kind, defaultValue: value) 111 | 112 | let observable = Experiments.Observable(store) { [weak self] in 113 | await self?.refresh() 114 | } 115 | self.observable = observable 116 | 117 | await withCheckedContinuation { continuation in 118 | withObservationTracking { 119 | let states = observable.states 120 | #expect(states.count == 1) 121 | if let first = try? #require(states.first) { 122 | #expect(first.experiment == kind) 123 | #expect(first.value as? Int == value) 124 | } 125 | } onChange: { 126 | continuation.resume() 127 | } 128 | 129 | Task.detached { 130 | store.raw[experimentFor: key] = .init(experiment: .integerRange(0...200), defaultValue: value) 131 | } 132 | } 133 | } 134 | } 135 | 136 | @MainActor 137 | @Test(.timeLimit(.minutes(1))) 138 | @available(macOS 14, *) 139 | @available(iOS 17, *) 140 | @available(tvOS 17, *) 141 | @available(watchOS 10, *) 142 | @available(visionOS 1, *) 143 | func testValuesObservableInActor() async { 144 | let actor = IsolatedOtherwise() 145 | await actor.testValuesObservable() 146 | } 147 | 148 | @MainActor 149 | @Test(.timeLimit(.minutes(1))) 150 | @available(macOS 14, *) 151 | @available(iOS 17, *) 152 | @available(tvOS 17, *) 153 | @available(watchOS 10, *) 154 | @available(visionOS 1, *) 155 | func testKindsObservableInActor() async { 156 | let actor = IsolatedOtherwise() 157 | await actor.testKindsObservable() 158 | } 159 | } 160 | #endif 161 | -------------------------------------------------------------------------------- /Tests/ExperimentsBoardTests/Tests - Storage.swift: -------------------------------------------------------------------------------- 1 | 2 | import Testing 3 | @testable import ExperimentsBoard 4 | 5 | extension Experiments { 6 | var values: [AnyHashable: any Sendable & Hashable] { 7 | Dictionary(uniqueKeysWithValues: valuesWithRawKeys.map { 8 | (AnyHashable($0), $1.base) 9 | }) 10 | } 11 | } 12 | 13 | @Suite 14 | struct ExperimentsStorageTests { 15 | @Test func settingGetting() { 16 | let store = Experiments.Storage() 17 | store.raw[raw: "Wow"] = .init(42) 18 | #expect(store.raw["Wow"] as? Int == 42) 19 | } 20 | 21 | final actor TestObserver: ExperimentsObserver { 22 | var experimentValuesDidChangeWasCalled = false 23 | nonisolated func experimentValuesDidChange() { 24 | Task { 25 | await set() 26 | } 27 | } 28 | 29 | var trueAwaiters: [CheckedContinuation] = [] 30 | var falseAwaiters: [CheckedContinuation] = [] 31 | func expect(_ value: Bool) async { 32 | guard experimentValuesDidChangeWasCalled != value else { 33 | return 34 | } 35 | 36 | await withCheckedContinuation { continuation in 37 | if value { 38 | trueAwaiters.append(continuation) 39 | } else { 40 | falseAwaiters.append(continuation) 41 | } 42 | } 43 | } 44 | 45 | func set() { 46 | experimentValuesDidChangeWasCalled = true 47 | let awaiters = self.trueAwaiters 48 | self.trueAwaiters = [] 49 | for awaiter in awaiters { 50 | awaiter.resume() 51 | } 52 | } 53 | func reset() { 54 | experimentValuesDidChangeWasCalled = false 55 | let awaiters = self.falseAwaiters 56 | self.trueAwaiters = [] 57 | for awaiter in awaiters { 58 | awaiter.resume() 59 | } 60 | } 61 | } 62 | 63 | @Test(.timeLimit(.minutes(1))) 64 | @available(macOS 13, *) 65 | @available(iOS 16, *) 66 | @available(tvOS 16, *) 67 | @available(watchOS 9, *) 68 | @available(visionOS 1, *) 69 | func observing() async { 70 | let test = TestObserver() 71 | let store = Experiments.Storage() 72 | store.addObserver(test) 73 | await test.expect(false) 74 | 75 | store.raw.set(42, for: "Wow") 76 | await test.expect(true) 77 | } 78 | 79 | @Test(.timeLimit(.minutes(1))) 80 | @available(macOS 13, *) 81 | @available(iOS 16, *) 82 | @available(tvOS 16, *) 83 | @available(watchOS 9, *) 84 | @available(visionOS 1, *) 85 | func endObserving() async { 86 | let test = TestObserver() 87 | let store = Experiments.Storage() 88 | 89 | store.addObserver(test) 90 | await test.expect(false) 91 | store.raw.set(42, for: "Wow") 92 | await test.expect(true) 93 | 94 | await test.reset() 95 | store.removeObserver(test) 96 | store.raw.set(24, for: "Wow") 97 | await test.expect(false) 98 | } 99 | 100 | @Test func values() { 101 | let store = Experiments.Storage() 102 | #expect(store.raw.values as? [String: Int] == [:]) 103 | store.raw.set(42, for: "Wow") 104 | #expect(store.raw.values as? [String: Int] == ["Wow": 42]) 105 | store.raw.set(252525, for: "Nice") 106 | #expect(store.raw.values as? [String: Int] == ["Wow": 42, "Nice": 252525]) 107 | } 108 | } 109 | 110 | @Suite 111 | struct ExperimentsSnapshots { 112 | @Test func creating() { 113 | let store = Experiments.Storage() 114 | let a = Experiments(store) 115 | #expect(a.values["Test"] == nil) 116 | store.raw.set(42, for: "Test") 117 | #expect(a.values["Test"] == nil) 118 | 119 | let b = Experiments(store) 120 | #expect(b.values["Test"] as? Int == 42) 121 | } 122 | 123 | @Test func usingWithString() { 124 | let store = Experiments.Storage() 125 | let a = Experiments(store) 126 | #expect(store.raw[experimentFor: "Test"] == nil) 127 | #expect(a.value("Default Value", key: "Test") == "Default Value") 128 | #expect(store.raw[experimentFor: "Test"]?.experiment == .string) 129 | #expect(store.raw[experimentFor: "Test"]?.defaultValue.base as? String == "Default Value") 130 | 131 | store.raw.set("Wow", for: "Test") 132 | #expect(a.value("Default Value", key: "Test") == "Default Value") 133 | 134 | let b = Experiments(store) 135 | #expect(b.value("Default Value", key: "Test") == "Wow") 136 | } 137 | 138 | @Test func gettingAllExperiments() throws { 139 | let store = Experiments.Storage() 140 | let a = Experiments(store) 141 | #expect(store.raw[experimentFor: "Test"] == nil) 142 | #expect(a.value("Default Value", key: "Test") == "Default Value") 143 | #expect(store.raw[experimentFor: "Test"]?.experiment == .string) 144 | #expect(store.raw[experimentFor: "Test"]?.experiment == .string) 145 | 146 | let experiments = store.raw.experiments 147 | #expect(experiments.count == 1) 148 | let experiment = try #require(experiments["Test"]) 149 | #expect(experiment.experiment == .string) 150 | #expect(experiment.defaultValue.base as? String == "Default Value") 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Tests/ExperimentsBoardTests/Tests - Wrapper.swift: -------------------------------------------------------------------------------- 1 | 2 | #if canImport(Observation) 3 | import Testing 4 | import Observation 5 | @testable import ExperimentsBoard 6 | 7 | fileprivate struct _UnsafeSendable { 8 | nonisolated(unsafe) let value: Value 9 | } 10 | 11 | @Suite 12 | struct WrapperTests { 13 | @MainActor 14 | @available(macOS 14, *) 15 | @available(iOS 17, *) 16 | @available(tvOS 17, *) 17 | @available(watchOS 10, *) 18 | @available(visionOS 1, *) 19 | @Test(.timeLimit(.minutes(1))) func wrapperValueReadingString() async { 20 | let key = "Hello" 21 | let defaultValue = "Hi!" 22 | let newValue = "Nice!" 23 | 24 | let store = Experiments.Storage() 25 | let observable = Experiments.Observable(store) 26 | 27 | #expect(store.raw[experimentFor: key] == nil) 28 | 29 | @Experimentable(key, observing: observable) 30 | var nice = defaultValue 31 | 32 | #expect(store.raw[experimentFor: key] == nil) 33 | #expect(nice == defaultValue) 34 | #expect(store.raw[experimentFor: key] == .init(experiment: .string, defaultValue: defaultValue)) 35 | 36 | await withCheckedContinuation { continuation in 37 | withObservationTracking { 38 | _ = nice 39 | } onChange: { 40 | continuation.resume() 41 | } 42 | 43 | store.raw.set(newValue, for: key) 44 | } 45 | 46 | #expect(nice == newValue) 47 | } 48 | 49 | @MainActor 50 | @available(macOS 14, *) 51 | @available(iOS 17, *) 52 | @available(tvOS 17, *) 53 | @available(watchOS 10, *) 54 | @available(visionOS 1, *) 55 | @Test(.timeLimit(.minutes(1))) func wrapperValueReadingInt() async { 56 | let key = "Hello" 57 | let defaultValue = 123 58 | let newValue = 456 59 | let range = 0...1000 60 | 61 | let store = Experiments.Storage() 62 | let observable = Experiments.Observable(store) 63 | 64 | #expect(store.raw[experimentFor: key] == nil) 65 | 66 | @Experimentable(key, in: range, observing: observable) 67 | var nice = defaultValue 68 | 69 | #expect(store.raw[experimentFor: key] == nil) 70 | #expect(nice == defaultValue) 71 | #expect(store.raw[experimentFor: key] == .init(experiment: .integerRange(range), defaultValue: defaultValue)) 72 | 73 | await withCheckedContinuation { continuation in 74 | withObservationTracking { 75 | _ = nice 76 | } onChange: { 77 | continuation.resume() 78 | } 79 | 80 | store.raw.set(newValue, for: key) 81 | } 82 | 83 | #expect(nice == newValue) 84 | } 85 | 86 | @MainActor 87 | @available(macOS 14, *) 88 | @available(iOS 17, *) 89 | @available(tvOS 17, *) 90 | @available(watchOS 10, *) 91 | @available(visionOS 1, *) 92 | @Test(.timeLimit(.minutes(1))) func wrapperValueReadingDouble() async { 93 | let key = "Hello" 94 | let defaultValue = 123.0 95 | let newValue = 456.0 96 | let range = 0.0...1000.0 97 | 98 | let store = Experiments.Storage() 99 | let observable = Experiments.Observable(store) 100 | 101 | #expect(store.raw[experimentFor: key] == nil) 102 | 103 | @Experimentable(key, in: range, observing: observable) 104 | var nice = defaultValue 105 | 106 | #expect(store.raw[experimentFor: key] == nil) 107 | #expect(nice == defaultValue) 108 | #expect(store.raw[experimentFor: key] == .init(experiment: .floatingPointRange(range), defaultValue: defaultValue)) 109 | 110 | await withCheckedContinuation { continuation in 111 | withObservationTracking { 112 | _ = nice 113 | } onChange: { 114 | continuation.resume() 115 | } 116 | 117 | store.raw.set(newValue, for: key) 118 | } 119 | 120 | #expect(nice == newValue) 121 | } 122 | 123 | @available(macOS 14, *) 124 | @available(iOS 17, *) 125 | @available(tvOS 17, *) 126 | @available(watchOS 10, *) 127 | @available(visionOS 1, *) 128 | actor IsolatedOtherwise { 129 | let store = Experiments.Storage() 130 | var observable: Experiments.Observable? 131 | 132 | private func observe() -> Experiments.Observable { 133 | if let observable { 134 | return observable 135 | } else { 136 | let observable = Experiments.Observable(store) { [weak self] in 137 | await self?.refresh() 138 | } 139 | self.observable = observable 140 | return observable 141 | } 142 | } 143 | 144 | private func refresh() { 145 | observable?.refresh() 146 | } 147 | 148 | func wrapperValueReadingString() async { 149 | let key = "Hello" 150 | let defaultValue = "Hi!" 151 | let newValue = "Nice!" 152 | 153 | let observable = observe() 154 | 155 | #expect(store.raw[experimentFor: key] == nil) 156 | 157 | @Experimentable(key, observing: observable) 158 | var nice = defaultValue 159 | 160 | #expect(store.raw[experimentFor: key] == nil) 161 | #expect(nice == defaultValue) 162 | #expect(store.raw[experimentFor: key] == .init(experiment: .string, defaultValue: defaultValue)) 163 | 164 | let smuggle = _UnsafeSendable(value: _nice) 165 | await withCheckedContinuation { continuation in 166 | withObservationTracking { 167 | @Experimentable(from: smuggle.value) var nice 168 | _ = nice 169 | } onChange: { 170 | continuation.resume() 171 | } 172 | 173 | store.raw.set(newValue, for: key) 174 | } 175 | 176 | #expect(nice == newValue) 177 | } 178 | 179 | func wrapperValueReadingInt() async { 180 | let key = "Hello" 181 | let defaultValue = 123 182 | let newValue = 456 183 | let range = 0...1000 184 | 185 | let observable = observe() 186 | 187 | #expect(store.raw[experimentFor: key] == nil) 188 | 189 | @Experimentable(key, in: range, observing: observable) 190 | var nice = defaultValue 191 | 192 | #expect(store.raw[experimentFor: key] == nil) 193 | #expect(nice == defaultValue) 194 | #expect(store.raw[experimentFor: key] == .init(experiment: .integerRange(range), defaultValue: defaultValue)) 195 | 196 | let smuggle = _UnsafeSendable(value: _nice) 197 | await withCheckedContinuation { continuation in 198 | withObservationTracking { 199 | @Experimentable(from: smuggle.value) var nice 200 | _ = nice 201 | } onChange: { 202 | continuation.resume() 203 | } 204 | 205 | store.raw.set(newValue, for: key) 206 | } 207 | 208 | #expect(nice == newValue) 209 | } 210 | 211 | func wrapperValueReadingDouble() async { 212 | let key = "Hello" 213 | let defaultValue = 123.0 214 | let newValue = 456.0 215 | let range = 0.0...1000.0 216 | 217 | let observable = observe() 218 | 219 | #expect(store.raw[experimentFor: key] == nil) 220 | 221 | @Experimentable(key, in: range, observing: observable) 222 | var nice = defaultValue 223 | 224 | #expect(store.raw[experimentFor: key] == nil) 225 | #expect(nice == defaultValue) 226 | #expect(store.raw[experimentFor: key] == .init(experiment: .floatingPointRange(range), defaultValue: defaultValue)) 227 | 228 | let smuggle = _UnsafeSendable(value: _nice) 229 | await withCheckedContinuation { continuation in 230 | withObservationTracking { 231 | @Experimentable(from: smuggle.value) var nice 232 | _ = nice 233 | } onChange: { 234 | continuation.resume() 235 | } 236 | 237 | store.raw.set(newValue, for: key) 238 | } 239 | 240 | #expect(nice == newValue) 241 | } 242 | } 243 | 244 | @available(macOS 14, *) 245 | @available(iOS 17, *) 246 | @available(tvOS 17, *) 247 | @available(watchOS 10, *) 248 | @available(visionOS 1, *) 249 | @Test(.timeLimit(.minutes(1))) func wrapperValueReadingStringInActor() async { 250 | let isolated = IsolatedOtherwise() 251 | await isolated.wrapperValueReadingString() 252 | } 253 | 254 | @available(macOS 14, *) 255 | @available(iOS 17, *) 256 | @available(tvOS 17, *) 257 | @available(watchOS 10, *) 258 | @available(visionOS 1, *) 259 | @Test(.timeLimit(.minutes(1))) func wrapperValueReadingIntInActor() async { 260 | let isolated = IsolatedOtherwise() 261 | await isolated.wrapperValueReadingInt() 262 | } 263 | 264 | @available(macOS 14, *) 265 | @available(iOS 17, *) 266 | @available(tvOS 17, *) 267 | @available(watchOS 10, *) 268 | @available(visionOS 1, *) 269 | @Test(.timeLimit(.minutes(1))) func wrapperValueReadingDoubleInActor() async { 270 | let isolated = IsolatedOtherwise() 271 | await isolated.wrapperValueReadingDouble() 272 | } 273 | } 274 | 275 | #endif 276 | --------------------------------------------------------------------------------