Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions TestApp/Config/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<dict>
<key>LDMobileKey</key>
<string>$(LAUNCHDARKLY_MOBILE_KEY)</string>
<key>LDSecondaryMobileKey</key>
<string>$(LAUNCHDARKLY_SECONDARY_MOBILE_KEY)</string>
<key>LDEnvironment</key>
<string>$(LAUNCHDARKLY_ENVIRONMENT)</string>
</dict>
Expand Down
3 changes: 3 additions & 0 deletions TestApp/Config/Secrets.xcconfig.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion TestApp/Config/TestApp.xcconfig
Original file line number Diff line number Diff line change
@@ -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"
20 changes: 18 additions & 2 deletions TestApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 52 additions & 13 deletions TestApp/Sources/ClientController.swift
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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<String> = []
/// Flags each running client already reports changes of, so evaluating one again does not add another observer.
private var observedFlags: [TestEnvironment: Set<String>] = [:]

/// What the running client was started with, for the scenarios that depend on its limits.
private(set) var config: LDConfig?
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}
}

Expand All @@ -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
Expand Down
27 changes: 22 additions & 5 deletions TestApp/Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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.")
}
}
}

Expand Down Expand Up @@ -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))"
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions TestApp/Sources/InstanceLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Expand Down
7 changes: 4 additions & 3 deletions TestApp/Sources/OversizedPostProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
42 changes: 38 additions & 4 deletions TestApp/Sources/TestAppConfiguration.swift
Original file line number Diff line number Diff line change
@@ -1,27 +1,42 @@
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
)

var environmentName: String { isStaging ? "staging" : "production" }

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
Expand All @@ -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 }
}
Loading