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 } +}