From a796dca469b2e3830f0e6b1c60d9adedf3cafb19 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 10 Nov 2026 17:46:00 -0800 Subject: [PATCH] Start a second environment in the test app when a second key is set LAUNCHDARKLY_SECONDARY_MOBILE_KEY in Secrets.xcconfig, or -LDSecondaryMobileKey at launch, adds a secondary mobile key to the client's LDConfig. An Environment picker then chooses which environment's client the buttons, the connection status, Post 20 MB and the Event storage section use. Restart, Flush, Flush+Wait, Offline and Identify still reach both, as the SDK applies them to every environment. Co-authored-by: Cursor --- TestApp/Config/Info.plist | 2 + TestApp/Config/Secrets.xcconfig.example | 3 + TestApp/Config/TestApp.xcconfig | 4 +- TestApp/README.md | 20 ++++++- TestApp/Sources/ClientController.swift | 65 +++++++++++++++++----- TestApp/Sources/ContentView.swift | 27 +++++++-- TestApp/Sources/InstanceLauncher.swift | 6 +- TestApp/Sources/OversizedPostProbe.swift | 7 ++- TestApp/Sources/TestAppConfiguration.swift | 42 ++++++++++++-- 9 files changed, 145 insertions(+), 31 deletions(-) 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 } +}