diff --git a/TestApp/Config/Info.plist b/TestApp/Config/Info.plist
index 204be96d..38d55d5f 100644
--- a/TestApp/Config/Info.plist
+++ b/TestApp/Config/Info.plist
@@ -4,6 +4,8 @@
LDMobileKey
$(LAUNCHDARKLY_MOBILE_KEY)
+ LDSecondaryMobileKey
+ $(LAUNCHDARKLY_SECONDARY_MOBILE_KEY)
LDEnvironment
$(LAUNCHDARKLY_ENVIRONMENT)
diff --git a/TestApp/Config/Secrets.xcconfig.example b/TestApp/Config/Secrets.xcconfig.example
index 7d9e3ace..9cf97af8 100644
--- a/TestApp/Config/Secrets.xcconfig.example
+++ b/TestApp/Config/Secrets.xcconfig.example
@@ -2,5 +2,8 @@
LAUNCHDARKLY_MOBILE_KEY = mob-...
+// Optional: a second environment's key. The client then starts both, and a picker chooses which one the buttons use.
+// LAUNCHDARKLY_SECONDARY_MOBILE_KEY = mob-...
+
// production, or staging for LaunchDarkly's ld-stg endpoints.
LAUNCHDARKLY_ENVIRONMENT = production
diff --git a/TestApp/Config/TestApp.xcconfig b/TestApp/Config/TestApp.xcconfig
index 6502aa76..f7a6609a 100644
--- a/TestApp/Config/TestApp.xcconfig
+++ b/TestApp/Config/TestApp.xcconfig
@@ -1,7 +1,9 @@
// Settings every TestApp target shares. The mobile key lives in Secrets.xcconfig, which is not checked in: copy
-// Secrets.xcconfig.example next to it. Without it the app builds and says what is missing.
+// Secrets.xcconfig.example next to it. Without it the app builds and says what is missing. A second key there starts
+// a second environment alongside the first.
LAUNCHDARKLY_MOBILE_KEY =
+LAUNCHDARKLY_SECONDARY_MOBILE_KEY =
LAUNCHDARKLY_ENVIRONMENT = production
#include? "Secrets.xcconfig"
diff --git a/TestApp/README.md b/TestApp/README.md
index 0ad3da7e..36481a49 100644
--- a/TestApp/README.md
+++ b/TestApp/README.md
@@ -24,8 +24,24 @@ LAUNCHDARKLY_MOBILE_KEY = mob-...
LAUNCHDARKLY_ENVIRONMENT = production
```
-Set `LAUNCHDARKLY_ENVIRONMENT = staging` to use LaunchDarkly's staging endpoints. Either value can also be overridden at
-launch, without a rebuild, with `-LDMobileKey mob-...` or `-LDEnvironment staging`.
+Set `LAUNCHDARKLY_ENVIRONMENT = staging` to use LaunchDarkly's staging endpoints. Any value can also be overridden at
+launch, without a rebuild, with `-LDMobileKey mob-...`, `-LDSecondaryMobileKey mob-...` or `-LDEnvironment staging`.
+
+### Two environments
+
+Add a second key to start a second environment alongside the first, from the same `LDConfig`:
+
+```
+LAUNCHDARKLY_SECONDARY_MOBILE_KEY = mob-...
+```
+
+An **Environment** picker then appears at the top of **Client**, and chooses which environment's client the buttons use:
+the flag evaluations, Track, the event-loss and stress scenarios, Post 20 MB, the connection status and **Event
+storage**, which shows that environment's own directory. The choice is saved, so the launch that answers a kill scenario
+shows the same environment.
+
+Restart, the persistence picker, Flush, Flush+Wait, Offline and Identify reach both environments whichever is selected,
+because the SDK applies `close`, `flush`, `flushAndWait`, `setOnline` and `identify` to every environment it started.
Running on a device needs a development team, set in the target's Signing & Capabilities. The simulators and the Mac
need none.
diff --git a/TestApp/Sources/ClientController.swift b/TestApp/Sources/ClientController.swift
index 8bb69501..742d1a0d 100644
--- a/TestApp/Sources/ClientController.swift
+++ b/TestApp/Sources/ClientController.swift
@@ -1,10 +1,11 @@
import Foundation
@preconcurrency import LaunchDarkly
-/// Starts, restarts and reports on the one `LDClient` the app uses.
+/// Starts, restarts and reports on the `LDClient` the app uses, with a second environment where the build gives a key.
///
-/// Every button acts on `LDClient.get()`, which is nil while a restart is under way, so a tap meanwhile does nothing
-/// rather than reaching a client that is closing.
+/// Every button acts on the selected environment's client, which is nil while a restart is under way, so a tap
+/// meanwhile does nothing rather than reaching a client that is closing. Some calls reach every environment whichever
+/// client they are made on: `flush`, `flushAndWait`, `setOnline`, `identify` and `close`.
@MainActor
final class ClientController: ObservableObject {
static let defaultUserKey = "user key"
@@ -17,6 +18,7 @@ final class ClientController: ObservableObject {
private nonisolated static let startWaitSeconds: TimeInterval = 10
private static let persistenceKey = "eventPersistence"
+ private static let environmentKey = "selectedEnvironment"
private static let defaultPersistence = EventPersistence.immediate
let configuration = TestAppConfiguration.current
@@ -25,6 +27,8 @@ final class ClientController: ObservableObject {
/// launch has to read the disk the same way. Under `.disabled` the events a dead process left there are not
/// delivered.
@Published private(set) var persistence: EventPersistence
+ /// Saved too, so the launch that answers a kill scenario shows the environment it was run against.
+ @Published private(set) var environment: TestEnvironment
@Published private(set) var isOffline = false
@Published private(set) var isRestarting = false
@Published private(set) var connectionStatus = "Not started"
@@ -33,8 +37,8 @@ final class ClientController: ObservableObject {
private(set) var evaluationsRequested = 0
- /// Flags the running client already reports changes of, so evaluating one again does not add another observer.
- private var observedFlags: Set = []
+ /// Flags each running client already reports changes of, so evaluating one again does not add another observer.
+ private var observedFlags: [TestEnvironment: Set] = [:]
/// What the running client was started with, for the scenarios that depend on its limits.
private(set) var config: LDConfig?
@@ -49,11 +53,30 @@ final class ClientController: ObservableObject {
init() {
let saved = UserDefaults.standard.object(forKey: Self.persistenceKey) as? Int
persistence = saved.flatMap(EventPersistence.init(rawValue:)) ?? Self.defaultPersistence
+ let available = TestAppConfiguration.current.environments
+ let savedEnvironment = UserDefaults.standard.string(forKey: Self.environmentKey).flatMap(TestEnvironment.init)
+ environment = savedEnvironment.flatMap { available.contains($0) ? $0 : nil } ?? .primary
refreshDedupeStatus()
}
var client: LDClient? {
- isRestarting ? nil : LDClient.get()
+ client(for: environment)
+ }
+
+ func client(for environment: TestEnvironment) -> LDClient? {
+ isRestarting ? nil : LDClient.get(environment: environment.sdkName)
+ }
+
+ var mobileKey: String {
+ configuration.mobileKey(for: environment)
+ }
+
+ func select(_ chosen: TestEnvironment) {
+ guard chosen != environment, configuration.environments.contains(chosen)
+ else { return }
+ environment = chosen
+ UserDefaults.standard.set(chosen.rawValue, forKey: Self.environmentKey)
+ refreshConnectionStatus()
}
func start() {
@@ -93,9 +116,13 @@ final class ClientController: ObservableObject {
isRestarting = true
let config = makeConfig()
let context = Self.context(key: Self.defaultUserKey)
+ let environments = configuration.environments
lifecycleQueue.async {
if let previous = LDClient.get() {
- previous.stopObserving(owner: self)
+ for environment in environments {
+ LDClient.get(environment: environment.sdkName)?.stopObserving(owner: self)
+ }
+ // Closes every environment.
previous.close()
}
LDClient.start(config: config, context: context, startWaitSeconds: Self.startWaitSeconds) { timedOut in
@@ -132,12 +159,14 @@ final class ClientController: ObservableObject {
refreshDedupeStatus()
}
- /// Calls `changed` with the key of a flag each time its value changes, from the first call for that key on.
- func observeFlag(_ key: String, changed: @escaping @MainActor (String) -> Void) {
- guard let client, observedFlags.insert(key).inserted
+ /// Calls `changed` with the key of a flag and the environment it is in each time its value changes, from the first
+ /// call for that key in the selected environment on.
+ func observeFlag(_ key: String, changed: @escaping @MainActor (String, TestEnvironment) -> Void) {
+ let environment = self.environment
+ guard let client, observedFlags[environment, default: []].insert(key).inserted
else { return }
client.observe(key: key, owner: self) { change in
- Task { @MainActor in changed(change.key) }
+ Task { @MainActor in changed(change.key, environment) }
}
}
@@ -154,15 +183,25 @@ final class ClientController: ObservableObject {
connectionStatus = status
}
+ /// Every environment, not only the selected one, so switching shows the other's status without observing it again.
private func observeConnection() {
refreshConnectionStatus()
- client?.observeCurrentConnectionMode(owner: self) { [weak self] _ in
- Task { @MainActor in self?.refreshConnectionStatus() }
+ for environment in configuration.environments {
+ client(for: environment)?.observeCurrentConnectionMode(owner: self) { [weak self] _ in
+ Task { @MainActor in self?.refreshConnectionStatus() }
+ }
}
}
private func makeConfig() -> LDConfig {
var config = LDConfig(mobileKey: configuration.mobileKey, autoEnvAttributes: .enabled)
+ if configuration.hasSecondaryEnvironment {
+ do {
+ try config.setSecondaryMobileKeys([TestEnvironment.secondary.sdkName: configuration.secondaryMobileKey])
+ } catch {
+ print("secondary environment not started: \(error)")
+ }
+ }
config.startOnline = !isOffline
config.eventPersistence = persistence
// Same shape an application uses for any hook: wrap it at registration. Each wrapper has its own window, so
diff --git a/TestApp/Sources/ContentView.swift b/TestApp/Sources/ContentView.swift
index 3e11a7c4..c477fab2 100644
--- a/TestApp/Sources/ContentView.swift
+++ b/TestApp/Sources/ContentView.swift
@@ -12,7 +12,9 @@ struct ContentView: View {
EventsSection()
EventLossSection()
StressSection()
- StorageSection(mobileKey: controller.configuration.mobileKey)
+ // A new identity per environment, so the inspector is made again for the other directory.
+ StorageSection(mobileKey: controller.mobileKey)
+ .id(controller.environment)
DedupeSection()
}
.compactForm()
@@ -33,6 +35,14 @@ private struct ClientSection: View {
var body: some View {
Section {
+ if controller.configuration.hasSecondaryEnvironment {
+ Picker("Environment", selection: Binding(get: { controller.environment },
+ set: { controller.select($0) })) {
+ ForEach(controller.configuration.environments) { Text($0.title).tag($0) }
+ }
+ .segmentedWherePossible()
+ .accessibilityIdentifier("environment_picker")
+ }
Text(controller.connectionStatus)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
@@ -73,6 +83,11 @@ private struct ClientSection: View {
.textCase(nil)
#endif
}
+ } footer: {
+ if controller.configuration.hasSecondaryEnvironment {
+ Text("Restart, Flush, Flush+Wait, Offline and Identify reach both environments, as the SDK does. "
+ + "The other buttons and Event storage use the selected one.")
+ }
}
}
@@ -146,8 +161,8 @@ private struct FlagSection: View {
let controller = self.controller
controller.countEvaluation()
controller.result = "\(key) = \(describe(client, key))"
- controller.observeFlag(key) { [weak controller] changed in
- guard let controller, let client = controller.client
+ controller.observeFlag(key) { [weak controller] changed, environment in
+ guard let controller, let client = controller.client(for: environment)
else { return }
controller.countEvaluation()
controller.result = "\(changed) changed to \(describe(client, changed))"
@@ -265,7 +280,8 @@ private struct StorageSection: View {
}
} header: {
HStack {
- Text("Event storage")
+ Text(controller.configuration.hasSecondaryEnvironment
+ ? "Event storage · \(controller.environment.title)" : "Event storage")
if InstanceLauncher.isAvailable {
Spacer()
Button("New instance") { controller.result = InstanceLauncher.launchAnother() }
@@ -396,9 +412,10 @@ private struct OversizedPostButton: View {
isRunning = true
let controller = self.controller
let configuration = controller.configuration
+ let environment = controller.environment
controller.result = "Posting 20 MB…"
Task {
- let report = await OversizedPostProbe.run(configuration: configuration)
+ let report = await OversizedPostProbe.run(configuration: configuration, environment: environment)
print("oversized post\n\(report)")
controller.result = "Oversized post finished"
results = BenchmarkButton.Results(title: "Oversized post", text: report)
diff --git a/TestApp/Sources/InstanceLauncher.swift b/TestApp/Sources/InstanceLauncher.swift
index a5d459e5..88cb9ce4 100644
--- a/TestApp/Sources/InstanceLauncher.swift
+++ b/TestApp/Sources/InstanceLauncher.swift
@@ -43,11 +43,11 @@ enum InstanceLauncher {
#endif
}
- /// The `-LDMobileKey` and `-LDEnvironment` overrides this instance was launched with, so the next one talks to the
- /// same environment.
+ /// The `-LDMobileKey`, `-LDSecondaryMobileKey` and `-LDEnvironment` overrides this instance was launched with, so
+ /// the next one talks to the same environments.
private static var forwardedArguments: [String] {
var forwarded: [String] = []
- for key in ["LDMobileKey", "LDEnvironment"] {
+ for key in ["LDMobileKey", "LDSecondaryMobileKey", "LDEnvironment"] {
if let value = UserDefaults.standard.volatileDomain(forName: UserDefaults.argumentDomain)[key] as? String {
forwarded += ["-\(key)", value]
}
diff --git a/TestApp/Sources/OversizedPostProbe.swift b/TestApp/Sources/OversizedPostProbe.swift
index 34fe1e35..131a4a40 100644
--- a/TestApp/Sources/OversizedPostProbe.swift
+++ b/TestApp/Sources/OversizedPostProbe.swift
@@ -23,8 +23,9 @@ enum OversizedPostProbe {
private static let systemName = "iOS"
#endif
- static func run(configuration: TestAppConfiguration) async -> String {
- var config = LDConfig(mobileKey: configuration.mobileKey, autoEnvAttributes: .disabled)
+ static func run(configuration: TestAppConfiguration, environment: TestEnvironment) async -> String {
+ let mobileKey = configuration.mobileKey(for: environment)
+ var config = LDConfig(mobileKey: mobileKey, autoEnvAttributes: .disabled)
configuration.applyEndpoints(to: &config)
let url = config.eventsUrl.appendingPathComponent(path)
let payloadId = UUID().uuidString
@@ -40,7 +41,7 @@ enum OversizedPostProbe {
var request = URLRequest(url: url)
request.httpMethod = "POST"
- request.setValue("api_key \(configuration.mobileKey)", forHTTPHeaderField: "Authorization")
+ request.setValue("api_key \(mobileKey)", forHTTPHeaderField: "Authorization")
request.setValue("\(systemName)/oversized-post-probe", forHTTPHeaderField: "User-Agent")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
diff --git a/TestApp/Sources/TestAppConfiguration.swift b/TestApp/Sources/TestAppConfiguration.swift
index 756e8fbd..b5863ce8 100644
--- a/TestApp/Sources/TestAppConfiguration.swift
+++ b/TestApp/Sources/TestAppConfiguration.swift
@@ -1,20 +1,23 @@
import Foundation
import LaunchDarkly
-/// What the app is built against: the mobile key and which LaunchDarkly environment it talks to.
+/// What the app is built against: the mobile keys and which LaunchDarkly environment they talk to.
///
-/// Both come from `Config/Secrets.xcconfig` through the Info.plist. A launch argument overrides either, so a script
-/// starting several instances can point them somewhere else without a rebuild: `-LDMobileKey mob-...` or
-/// `-LDEnvironment staging`.
+/// All come from `Config/Secrets.xcconfig` through the Info.plist. A launch argument overrides any of them, so a script
+/// starting several instances can point them somewhere else without a rebuild: `-LDMobileKey mob-...`,
+/// `-LDSecondaryMobileKey mob-...` or `-LDEnvironment staging`.
struct TestAppConfiguration {
// The staging hosts mirror the production ones under the ld-stg domain.
private static let stagingDomain = "ld-stg.launchdarkly.com"
let mobileKey: String
+ /// Empty unless the build gives a second key, in which case the client starts both environments.
+ let secondaryMobileKey: String
let isStaging: Bool
static let current = TestAppConfiguration(
mobileKey: value(for: "LDMobileKey"),
+ secondaryMobileKey: value(for: "LDSecondaryMobileKey"),
isStaging: value(for: "LDEnvironment").caseInsensitiveCompare("staging") == .orderedSame
)
@@ -22,6 +25,18 @@ struct TestAppConfiguration {
var isMissingMobileKey: Bool { mobileKey.isEmpty }
+ /// The SDK refuses a secondary key equal to the primary one, so that counts as no second environment.
+ var hasSecondaryEnvironment: Bool { !secondaryMobileKey.isEmpty && secondaryMobileKey != mobileKey }
+
+ var environments: [TestEnvironment] { hasSecondaryEnvironment ? TestEnvironment.allCases : [.primary] }
+
+ func mobileKey(for environment: TestEnvironment) -> String {
+ switch environment {
+ case .primary: return mobileKey
+ case .secondary: return secondaryMobileKey
+ }
+ }
+
/// Points the endpoints at staging where the build asks for it, and leaves the production defaults otherwise.
func applyEndpoints(to config: inout LDConfig) {
guard isStaging
@@ -39,3 +54,22 @@ struct TestAppConfiguration {
.trimmingCharacters(in: .whitespaces) ?? ""
}
}
+
+/// One of the environments the client is started with: the primary one always, the secondary one where the build gives
+/// it a key. Each has its own `LDClient`, event store and directory.
+enum TestEnvironment: String, CaseIterable, Identifiable {
+ case primary
+ case secondary
+
+ var id: String { rawValue }
+
+ /// The name the SDK knows the environment by, for `LDClient.get(environment:)`.
+ var sdkName: String {
+ switch self {
+ case .primary: return LDConfig.Constants.primaryEnvironmentName
+ case .secondary: return "secondary"
+ }
+ }
+
+ var title: String { rawValue.capitalized }
+}