diff --git a/.github/actions/build-test-app/action.yml b/.github/actions/build-test-app/action.yml
new file mode 100644
index 000000000..faf80b42f
--- /dev/null
+++ b/.github/actions/build-test-app/action.yml
@@ -0,0 +1,37 @@
+name: Build test app
+description: 'Build the test app for every platform it runs on.'
+
+runs:
+ using: composite
+ steps:
+ # Workaround for intermittent macos-15 runner issue where simulator
+ # runtimes aren't loaded. Listing devices forces the simulator service
+ # to initialize. See https://github.com/actions/runner-images/issues/12948
+ - name: Prepare simulator runtimes
+ shell: bash
+ run: xcrun simctl list devices available
+
+ - name: Build for iOS simulator
+ shell: bash
+ working-directory: TestApp
+ run: xcodebuild build -project TestApp.xcodeproj -scheme 'TestApp_iOS' -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO | xcpretty
+
+ - name: Build for Mac Catalyst
+ shell: bash
+ working-directory: TestApp
+ run: xcodebuild build -project TestApp.xcodeproj -scheme 'TestApp_iOS' -destination 'generic/platform=macOS,variant=Mac Catalyst' CODE_SIGNING_ALLOWED=NO | xcpretty
+
+ - name: Build for macOS
+ shell: bash
+ working-directory: TestApp
+ run: xcodebuild build -project TestApp.xcodeproj -scheme 'TestApp_macOS' -destination 'generic/platform=macOS' CODE_SIGNING_ALLOWED=NO | xcpretty
+
+ - name: Build for tvOS simulator
+ shell: bash
+ working-directory: TestApp
+ run: xcodebuild build -project TestApp.xcodeproj -scheme 'TestApp_tvOS' -destination 'generic/platform=tvOS Simulator' CODE_SIGNING_ALLOWED=NO | xcpretty
+
+ - name: Build for watchOS simulator
+ shell: bash
+ working-directory: TestApp
+ run: xcodebuild build -project TestApp.xcodeproj -scheme 'TestApp_watchOS' -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO | xcpretty
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3f42b0fd0..4ec6a48f7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -110,6 +110,19 @@ jobs:
- uses: ./.github/actions/build-watchos
+ # Xcode 16 or later only: the project uses folders Xcode keeps in sync, which earlier versions cannot open.
+ build-test-app:
+ runs-on: macos-15
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd
+ with:
+ xcode-version: 16.4.0
+
+ - uses: ./.github/actions/build-test-app
+
test-swiftpm:
runs-on: ${{ matrix.os }}
diff --git a/.gitignore b/.gitignore
index a9d93d096..db8b53d79 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,6 @@ xcuserdata
**/Package.resolved
/LaunchDarkly.xcworkspace/xcshareddata/swiftpm/Package.resolved
/LaunchDarkly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+
+# The test app's mobile key; see TestApp/Config/Secrets.xcconfig.example.
+/TestApp/Config/Secrets.xcconfig
diff --git a/TestApp/Config/Info.plist b/TestApp/Config/Info.plist
new file mode 100644
index 000000000..204be96da
--- /dev/null
+++ b/TestApp/Config/Info.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ LDMobileKey
+ $(LAUNCHDARKLY_MOBILE_KEY)
+ LDEnvironment
+ $(LAUNCHDARKLY_ENVIRONMENT)
+
+
diff --git a/TestApp/Config/Secrets.xcconfig.example b/TestApp/Config/Secrets.xcconfig.example
new file mode 100644
index 000000000..7d9e3aced
--- /dev/null
+++ b/TestApp/Config/Secrets.xcconfig.example
@@ -0,0 +1,6 @@
+// Copy this file to Secrets.xcconfig in the same directory and fill in the values. Secrets.xcconfig is ignored by git.
+
+LAUNCHDARKLY_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
new file mode 100644
index 000000000..6502aa766
--- /dev/null
+++ b/TestApp/Config/TestApp.xcconfig
@@ -0,0 +1,7 @@
+// 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.
+
+LAUNCHDARKLY_MOBILE_KEY =
+LAUNCHDARKLY_ENVIRONMENT = production
+
+#include? "Secrets.xcconfig"
diff --git a/TestApp/README.md b/TestApp/README.md
new file mode 100644
index 000000000..0ad3da7e1
--- /dev/null
+++ b/TestApp/README.md
@@ -0,0 +1,95 @@
+# LaunchDarkly Apple SDK test app
+
+This app is an internal quality harness, the counterpart of the Android SDK's `test-app`. It is not an integration
+example.
+
+It builds the SDK from this repository as a local Swift package, so every target runs the code checked out here. One
+folder of SwiftUI sources is shared by four targets, each with a scheme of the same name:
+
+| Scheme | Runs on |
+| --- | --- |
+| `TestApp_iOS` | iPhone and iPad, simulator or device, and the Mac through Mac Catalyst |
+| `TestApp_macOS` | the Mac, natively |
+| `TestApp_tvOS` | Apple TV, simulator or device |
+| `TestApp_watchOS` | Apple Watch, simulator or device, as a watch-only app |
+
+The project needs Xcode 16 or later.
+
+## Setup
+
+Copy `Config/Secrets.xcconfig.example` to `Config/Secrets.xcconfig`, which git ignores, and fill in the mobile key:
+
+```
+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`.
+
+Running on a device needs a development team, set in the target's Signing & Capabilities. The simulators and the Mac
+need none.
+
+## Event-loss scenarios
+
+Create a boolean flag named `kill-flag`, or enter another flag key under **Event loss**. Each scenario evaluates the
+flag and tracks `$ld:telemetry:error`, standing in for the error an application reports just before it dies, and then
+ends the process:
+
+- **Eval+Track+Flush, kill in 5 s** asks for a flush and sends `SIGKILL` five seconds later. The control: the events
+ should arrive on this launch.
+- **Eval+Kill now** sends `SIGKILL` at once. No handler or SDK code runs before the process ends.
+- **Eval+Fatal now** calls `fatalError` at once. Unlike an uncaught exception on Android, a Swift trap leaves no safe
+ place to flush from, so this behaves like the kill.
+
+The event persistence picker is saved across launches, and defaults to `immediate`. With that setting, the events from
+**Eval+Kill now** and **Eval+Fatal now** should be recovered and delivered on the next launch, though no process code
+ran on exit. Under `disabled` they are lost.
+
+**Event storage** shows what the SDK has on disk for this app and mobile key: `pending-` logs that processes append to,
+and `ready-` batches waiting for delivery, with the number of events each holds. It reads names and sizes only, so
+looking never changes what is there.
+
+## Several instances on a Mac
+
+Every instance of the Mac app, native or Catalyst, uses the same event directory, which is what Tier 3 has to get right
+on a Mac: each instance keeps its own open log, any instance can deliver a batch another closed, and the log of an
+instance that was killed is recovered by the next client to start.
+
+Start instances with **Launch another instance**, or from a terminal:
+
+```sh
+TestApp/scripts/launch-instances.sh 3 # three native instances
+TestApp/scripts/launch-instances.sh -c 2 # two Mac Catalyst instances
+TestApp/scripts/launch-instances.sh 2 -- -LDEnvironment staging
+```
+
+The window title shows each instance's process ID. A typical run: go offline in instance A, track a few events, kill A
+with **Eval+Kill now**, then press **Restart client** in instance B. B's storage view shows A's log turn into a batch,
+and B delivers it. Native and Catalyst apps have different bundle identifiers, so they do not share a directory with
+each other.
+
+## Over-refresh scenario
+
+Create a boolean flag named `trackevents-test` with event tracking enabled. **Over-refresh eval** compares the average
+evaluation cost while filling the event capacity and after it is full. The loop runs on the main thread on purpose: it
+stands in for a UI that re-evaluates a flag far more often than anything changed.
+
+## Fill 3 batches
+
+**Fill 3 batches** tracks twenty 256 KiB events, which take three 2 MiB batches. A flush then sends three requests, and
+offline, or followed by a kill, it leaves three `ready-` files for the next launch. Each event's index is in its data,
+so the dashboard shows whether all of them arrived and none twice.
+
+## Benchmark
+
+**Benchmark**, under the status text, runs the event persistence benchmark in the app's own process and shows its
+tables in a sheet, with **Copy** on iOS and the Mac. It is the Android test app's benchmark, section for section:
+building and serializing a feature event across context shapes and privacy settings, what an evaluation and a commit
+point cost, the tail of a commit point over 100, 1,000 and 10,000 calls, comparing contexts, and summary events. It
+measures stores of its own in a temporary directory, so it needs no client and leaves the event storage view alone.
+
+It is in Debug builds only, since it reaches the SDK's internals through `@testable import`. The tables are also
+printed to the console and appended to `Library/Caches/event-bench.txt` in the app's container. A run on a watch makes a
+tenth of the iterations. Read the figures as ratios between rows: a Debug build is unoptimized, and a live client shares
+the process.
diff --git a/TestApp/Sources/ClientController.swift b/TestApp/Sources/ClientController.swift
new file mode 100644
index 000000000..8bb695013
--- /dev/null
+++ b/TestApp/Sources/ClientController.swift
@@ -0,0 +1,217 @@
+import Foundation
+@preconcurrency import LaunchDarkly
+
+/// Starts, restarts and reports on the one `LDClient` the app uses.
+///
+/// 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.
+@MainActor
+final class ClientController: ObservableObject {
+ static let defaultUserKey = "user key"
+
+ // Two hooks with different windows, to show that each one is deduplicated on its own.
+ static let fastDedupeWindow: TimeInterval = 5
+ static let slowDedupeWindow: TimeInterval = 10
+
+ /// How long starting waits for the first flags before going on with whatever is cached.
+ private nonisolated static let startWaitSeconds: TimeInterval = 10
+
+ private static let persistenceKey = "eventPersistence"
+ private static let defaultPersistence = EventPersistence.immediate
+
+ let configuration = TestAppConfiguration.current
+
+ /// Saved rather than held in memory: the kill and crash scenarios are only answered on the next launch, and that
+ /// 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
+ @Published private(set) var isOffline = false
+ @Published private(set) var isRestarting = false
+ @Published private(set) var connectionStatus = "Not started"
+ @Published private(set) var dedupeStatus = ""
+ @Published var result = ""
+
+ 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 = []
+
+ /// What the running client was started with, for the scenarios that depend on its limits.
+ private(set) var config: LDConfig?
+
+ private lazy var fastHook = ExposureCountingHook(label: "fast") { [weak self] in self?.dedupeStatusChanged() }
+ private lazy var slowHook = ExposureCountingHook(label: "slow") { [weak self] in self?.dedupeStatusChanged() }
+
+ /// Closing waits on the event store and starting on the first flags, so both happen here rather than on the main
+ /// thread, one at a time.
+ private let lifecycleQueue = DispatchQueue(label: "com.launchdarkly.testapp.client-lifecycle")
+
+ init() {
+ let saved = UserDefaults.standard.object(forKey: Self.persistenceKey) as? Int
+ persistence = saved.flatMap(EventPersistence.init(rawValue:)) ?? Self.defaultPersistence
+ refreshDedupeStatus()
+ }
+
+ var client: LDClient? {
+ isRestarting ? nil : LDClient.get()
+ }
+
+ func start() {
+ guard !configuration.isMissingMobileKey, config == nil
+ else {
+ if configuration.isMissingMobileKey {
+ result = "Set LAUNCHDARKLY_MOBILE_KEY in TestApp/Config/Secrets.xcconfig and rebuild."
+ }
+ return
+ }
+ restart(reason: "Started")
+ }
+
+ func setPersistence(_ chosen: EventPersistence) {
+ guard chosen != persistence
+ else { return }
+ persistence = chosen
+ UserDefaults.standard.set(chosen.rawValue, forKey: Self.persistenceKey)
+ restart(reason: "Restarted with \(chosen.name)")
+ }
+
+ func setOffline(_ offline: Bool) {
+ isOffline = offline
+ client?.setOnline(!offline) { [weak self] in
+ Task { @MainActor in self?.refreshConnectionStatus() }
+ }
+ }
+
+ /// Closes the client and starts another, which is also what recovers the logs another instance left when it was
+ /// killed: recovery runs as a client starts.
+ ///
+ /// The replacement starts in the offline toggle's state rather than being switched after it starts, because
+ /// starting waits for flags, and a client online for that long can deliver the events stored before the change.
+ func restart(reason: String = "Restarted") {
+ guard !configuration.isMissingMobileKey, !isRestarting
+ else { return }
+ isRestarting = true
+ let config = makeConfig()
+ let context = Self.context(key: Self.defaultUserKey)
+ lifecycleQueue.async {
+ if let previous = LDClient.get() {
+ previous.stopObserving(owner: self)
+ previous.close()
+ }
+ LDClient.start(config: config, context: context, startWaitSeconds: Self.startWaitSeconds) { timedOut in
+ Task { @MainActor in
+ self.config = config
+ self.observedFlags.removeAll()
+ self.isRestarting = false
+ self.observeConnection()
+ // Starting offline also reports a timeout, without waiting for anything.
+ if !config.startOnline {
+ self.result = "\(reason) offline"
+ } else {
+ self.result = timedOut ? "\(reason); flags did not arrive in time" : reason
+ }
+ }
+ }
+ }
+ }
+
+ func identify(key typed: String) {
+ let key = typed.trimmingCharacters(in: .whitespaces)
+ // An empty key builds an invalid context, which identify rejects before it resets the hooks' dedupe caches.
+ // Falling back to the key the client started with still demonstrates that the reset happens either way.
+ client?.identify(context: Self.context(key: key.isEmpty ? Self.defaultUserKey : key)) { [weak self] result in
+ Task { @MainActor in
+ self?.result = "Identify: \(result)"
+ self?.refreshDedupeStatus()
+ }
+ }
+ }
+
+ func countEvaluation() {
+ evaluationsRequested += 1
+ 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
+ else { return }
+ client.observe(key: key, owner: self) { change in
+ Task { @MainActor in changed(change.key) }
+ }
+ }
+
+ func refreshConnectionStatus() {
+ guard let info = client?.getConnectionInformation()
+ else {
+ connectionStatus = isRestarting ? "Restarting" : "Not started"
+ return
+ }
+ var status = "\(info.currentConnectionMode.rawValue) · flags \(info.lastKnownFlagValidity.map(Self.format) ?? "never")"
+ if let failed = info.lastFailedConnection {
+ status += "\nfailed \(Self.format(failed)): \(info.lastConnectionFailureReason)"
+ }
+ connectionStatus = status
+ }
+
+ private func observeConnection() {
+ refreshConnectionStatus()
+ client?.observeCurrentConnectionMode(owner: self) { [weak self] _ in
+ Task { @MainActor in self?.refreshConnectionStatus() }
+ }
+ }
+
+ private func makeConfig() -> LDConfig {
+ var config = LDConfig(mobileKey: configuration.mobileKey, autoEnvAttributes: .enabled)
+ 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
+ // neither suppresses the other.
+ config.hooks = [
+ DedupingHook(fastHook, window: Self.fastDedupeWindow),
+ DedupingHook(slowHook, window: Self.slowDedupeWindow),
+ ]
+ configuration.applyEndpoints(to: &config)
+ return config
+ }
+
+ private nonisolated func dedupeStatusChanged() {
+ Task { @MainActor in self.refreshDedupeStatus() }
+ }
+
+ private func refreshDedupeStatus() {
+ dedupeStatus = """
+ evaluations \(evaluationsRequested)
+ \(fastHook.status(window: Self.fastDedupeWindow)) · \(slowHook.status(window: Self.slowDedupeWindow))
+ """
+ }
+
+ static func context(key: String) -> LDContext {
+ var builder = LDContextBuilder(key: key)
+ builder.trySetValue("email", .string("fake@example.com"))
+ switch builder.build() {
+ case .success(let context):
+ return context
+ case .failure:
+ // Only a key the builder refuses gets here; the default one never is.
+ return try! LDContextBuilder(key: defaultUserKey).build().get()
+ }
+ }
+
+ private static func format(_ date: Date) -> String {
+ date.formatted(date: .omitted, time: .standard)
+ }
+}
+
+extension EventPersistence {
+ static let choices: [EventPersistence] = [.disabled, .deferred, .immediate]
+
+ var name: String {
+ switch self {
+ case .disabled: return "disabled"
+ case .deferred: return "deferred"
+ case .immediate: return "immediate"
+ @unknown default: return "unknown"
+ }
+ }
+}
diff --git a/TestApp/Sources/ContentView.swift b/TestApp/Sources/ContentView.swift
new file mode 100644
index 000000000..3e11a7c4e
--- /dev/null
+++ b/TestApp/Sources/ContentView.swift
@@ -0,0 +1,561 @@
+import LaunchDarkly
+import SwiftUI
+
+struct ContentView: View {
+ @EnvironmentObject private var controller: ClientController
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ ClientSection()
+ FlagSection()
+ EventsSection()
+ EventLossSection()
+ StressSection()
+ StorageSection(mobileKey: controller.configuration.mobileKey)
+ DedupeSection()
+ }
+ .compactForm()
+ .navigationTitle(title)
+ .inlineTitle()
+ }
+ }
+
+ private var title: String {
+ InstanceLauncher.isAvailable ? "LD Test App · pid \(InstanceLauncher.processId)" : "LD Test App"
+ }
+}
+
+// MARK: - Sections
+
+private struct ClientSection: View {
+ @EnvironmentObject private var controller: ClientController
+
+ var body: some View {
+ Section {
+ Text(controller.connectionStatus)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ if !controller.result.isEmpty {
+ Text(controller.result)
+ .font(.caption)
+ .lineLimit(4)
+ .accessibilityIdentifier("result")
+ }
+ #if DEBUG
+ ButtonRow {
+ BenchmarkButton()
+ OversizedPostButton()
+ }
+ #endif
+ Picker("Persistence", selection: Binding(get: { controller.persistence },
+ set: { controller.setPersistence($0) })) {
+ ForEach(EventPersistence.choices, id: \.rawValue) { Text($0.name).tag($0) }
+ }
+ .segmentedWherePossible()
+ .disabled(controller.isRestarting)
+ .accessibilityIdentifier("persistence_picker")
+ #if os(tvOS) || os(watchOS)
+ offlineToggle
+ #endif
+ ButtonRow {
+ ActionButton("Restart", id: "restart_client_button") { controller.restart() }
+ .disabled(controller.isRestarting || controller.configuration.isMissingMobileKey)
+ ActionButton("Flush", id: "flush_button") { controller.client?.flush() }
+ ActionButton("Flush+Wait", id: "flush_and_wait_button") { flushAndWait() }
+ }
+ } header: {
+ HStack {
+ Text("Client · \(controller.configuration.environmentName)")
+ #if !os(tvOS) && !os(watchOS)
+ Spacer()
+ offlineToggle
+ .textCase(nil)
+ #endif
+ }
+ }
+ }
+
+ private var offlineToggle: some View {
+ Toggle("Offline", isOn: Binding(get: { controller.isOffline }, set: { controller.setOffline($0) }))
+ .fixedSize()
+ .accessibilityIdentifier("offline_toggle")
+ }
+
+ /// Off the main thread, since it blocks for as long as the delivery takes, up to the timeout.
+ private func flushAndWait() {
+ guard let client = controller.client
+ else { return }
+ let controller = self.controller
+ controller.result = "Flushing…"
+ DispatchQueue.global(qos: .userInitiated).async {
+ let started = Date()
+ let delivered = client.flushAndWait(timeout: 5)
+ let elapsed = Date().timeIntervalSince(started)
+ Task { @MainActor in
+ controller.result = String(format: "flushAndWait: %@ in %.2f s", delivered ? "delivered" : "not delivered", elapsed)
+ }
+ }
+ }
+}
+
+private struct FlagSection: View {
+ enum FlagType: String, CaseIterable, Identifiable {
+ case bool = "Bool"
+ case int = "Int"
+ case double = "Double"
+ case string = "String"
+ case json = "JSON"
+
+ var id: String { rawValue }
+ }
+
+ @EnvironmentObject private var controller: ClientController
+ @State private var flagKey = ""
+ @State private var type = FlagType.bool
+
+ var body: some View {
+ Section("Flag") {
+ ButtonRow {
+ TextField("flag key", text: $flagKey)
+ .plainKeyEntry()
+ .accessibilityIdentifier("flag_key_field")
+ Picker("Type", selection: $type) {
+ ForEach(FlagType.allCases) { Text($0.rawValue).tag($0) }
+ }
+ .labelsHidden()
+ .fixedSize()
+ ActionButton("Eval", id: "eval_button") { evaluate() }
+ }
+ ActionButton("All flags", id: "all_flags_button") { readAllFlags() }
+ }
+ }
+
+ /// How many flags the client holds. The keys are printed.
+ private func readAllFlags() {
+ guard let flags = controller.client?.allFlags
+ else { return }
+ controller.result = "Read \(flags.count) flags"
+ print("all flags: \(flags.keys.sorted())")
+ }
+
+ private func evaluate() {
+ let key = flagKey.trimmingCharacters(in: .whitespaces)
+ guard let client = controller.client, !key.isEmpty
+ else { return }
+ 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
+ else { return }
+ controller.countEvaluation()
+ controller.result = "\(changed) changed to \(describe(client, changed))"
+ }
+ }
+
+ private func describe(_ client: LDClient, _ key: String) -> String {
+ switch type {
+ case .bool: return String(client.boolVariation(forKey: key, defaultValue: false))
+ case .int: return String(client.intVariation(forKey: key, defaultValue: 0))
+ case .double: return String(client.doubleVariation(forKey: key, defaultValue: 0))
+ case .string: return client.stringVariation(forKey: key, defaultValue: "default")
+ case .json: return String(describing: client.jsonVariation(forKey: key, defaultValue: .null))
+ }
+ }
+}
+
+private struct EventsSection: View {
+ @EnvironmentObject private var controller: ClientController
+ @State private var userKey = ""
+
+ var body: some View {
+ Section("Events") {
+ ButtonRow {
+ TextField(ClientController.defaultUserKey, text: $userKey)
+ .plainKeyEntry()
+ .accessibilityIdentifier("user_key_field")
+ ActionButton("Identify", id: "identify_button") { controller.identify(key: userKey) }
+ ActionButton("Track", id: "track_button") { controller.client?.track(key: "Apple event name") }
+ }
+ }
+ }
+}
+
+private struct EventLossSection: View {
+ @EnvironmentObject private var controller: ClientController
+ @State private var flagKey = Scenarios.defaultKillFlag
+
+ var body: some View {
+ Section {
+ TextField(Scenarios.defaultKillFlag, text: $flagKey)
+ .plainKeyEntry()
+ .accessibilityIdentifier("kill_flag_key_field")
+ ButtonRow {
+ ActionButton("Kill 5s", tint: .red, id: "kill_unsent_button") {
+ Scenarios.evalTrackFlushThenKill(controller.client, flagKey: killFlag)
+ }
+ ActionButton("Kill now", tint: .red, id: "kill_now_button") {
+ Scenarios.evalTrackThenKillNow(controller.client, flagKey: killFlag)
+ }
+ ActionButton("Fatal now", tint: .red, id: "fatal_now_button") {
+ Scenarios.evalTrackThenFatalErrorNow(controller.client, flagKey: killFlag)
+ }
+ }
+ } header: {
+ Text("Event loss · eval + track, then")
+ } footer: {
+ Text("Kill 5s flushes first: arrives this launch. Kill now and Fatal now: next launch only.")
+ }
+ }
+
+ private var killFlag: String {
+ let key = flagKey.trimmingCharacters(in: .whitespaces)
+ return key.isEmpty ? Scenarios.defaultKillFlag : key
+ }
+}
+
+private struct StressSection: View {
+ @EnvironmentObject private var controller: ClientController
+
+ var body: some View {
+ Section("Stress") {
+ ButtonRow {
+ ActionButton("Over-refresh", tint: .orange, id: "over_refresh_button") {
+ let capacity = controller.config?.eventCapacity ?? 0
+ if let result = Scenarios.overRefreshEval(controller.client, eventCapacity: capacity) {
+ controller.result = result
+ }
+ }
+ ActionButton("Fill 3 batches", tint: .orange, id: "fill_batches_button") {
+ if let result = Scenarios.fillBatches(controller.client) {
+ controller.result = result
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct StorageSection: View {
+ @EnvironmentObject private var controller: ClientController
+ @StateObject private var inspector: EventStorageInspector
+
+ init(mobileKey: String) {
+ _inspector = StateObject(wrappedValue: EventStorageInspector(mobileKey: mobileKey))
+ }
+
+ var body: some View {
+ Section {
+ Text(inspector.summary)
+ .font(.callout)
+ .accessibilityIdentifier("storage_summary")
+ ForEach(inspector.entries) { entry in
+ HStack(spacing: 6) {
+ Image(systemName: symbol(for: entry.kind))
+ .foregroundStyle(entry.kind == .batch ? Color.accentColor : .secondary)
+ Text(entry.name)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Spacer(minLength: 4)
+ Text(describe(entry))
+ .foregroundStyle(.secondary)
+ }
+ .font(.caption.monospaced())
+ }
+ } header: {
+ HStack {
+ Text("Event storage")
+ if InstanceLauncher.isAvailable {
+ Spacer()
+ Button("New instance") { controller.result = InstanceLauncher.launchAnother() }
+ .textCase(nil)
+ .accessibilityIdentifier("launch_instance_button")
+ }
+ }
+ } footer: {
+ Text(inspector.directory.path)
+ .selectableWherePossible()
+ }
+ .onAppear { inspector.startWatching() }
+ .onDisappear { inspector.stopWatching() }
+ }
+
+ private func symbol(for kind: EventStorageInspector.Entry.Kind) -> String {
+ switch kind {
+ case .log: return "pencil.line"
+ case .opening: return "doc.badge.plus"
+ case .batch: return "shippingbox"
+ case .other: return "doc"
+ }
+ }
+
+ private func describe(_ entry: EventStorageInspector.Entry) -> String {
+ var parts: [String] = []
+ if let events = entry.events {
+ parts.append("\(events) ev")
+ }
+ parts.append(ByteCountFormatter.string(fromByteCount: Int64(entry.bytes), countStyle: .file))
+ if let modified = entry.modified {
+ parts.append(modified.formatted(date: .omitted, time: .shortened))
+ }
+ return parts.joined(separator: " · ")
+ }
+}
+
+private struct DedupeSection: View {
+ @EnvironmentObject private var controller: ClientController
+
+ var body: some View {
+ Section("Exposure dedupe · before/after") {
+ Text(controller.dedupeStatus)
+ .font(.caption.monospaced())
+ .accessibilityIdentifier("dedupe_status")
+ }
+ }
+}
+
+#if DEBUG
+/// Runs the event persistence benchmark in this process and shows its tables when it finishes. Off the main thread,
+/// since it takes up to a few minutes; the button stays disabled until it is done, so runs never measure each other.
+/// Needs no client: it measures stores of its own, in a temporary directory.
+///
+/// The tables are also printed, and appended to `event-bench.txt` in the app's caches directory.
+private struct BenchmarkButton: View {
+ struct Results: Identifiable {
+ let id = UUID()
+ let title: String
+ let text: String
+ }
+
+ @EnvironmentObject private var controller: ClientController
+ @State private var isRunning = false
+ @State private var results: Results?
+
+ var body: some View {
+ ActionButton("Benchmark", tint: .purple, id: "benchmark_button") { run() }
+ .disabled(isRunning)
+ .sheet(item: $results) { BenchmarkResultsView(results: $0) } }
+
+ private func run() {
+ isRunning = true
+ let controller = self.controller
+ controller.result = "Benchmark starting"
+ let scratch = FileManager.default.temporaryDirectory.appendingPathComponent("event-bench", isDirectory: true)
+ DispatchQueue.global(qos: .userInitiated).async {
+ let finished: Results
+ do {
+ let text = try EventPersistenceBenchmarkRunner.run(scratch: scratch) { section in
+ Task { @MainActor in controller.result = "Benchmark: \(section)…" }
+ }
+ print("event persistence benchmark\n\(text)")
+ Self.append(text)
+ finished = Results(title: "Event persistence benchmark", text: text)
+ } catch {
+ finished = Results(title: "Benchmark failed", text: String(describing: error))
+ }
+ Task { @MainActor in
+ controller.result = finished.title
+ results = finished
+ isRunning = false
+ }
+ }
+ }
+
+ private nonisolated static func append(_ text: String) {
+ guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
+ else { return }
+ let file = caches.appendingPathComponent("event-bench.txt")
+ let data = Data((text + "\n").utf8)
+ if let handle = try? FileHandle(forWritingTo: file) {
+ defer { try? handle.close() }
+ _ = try? handle.seekToEnd()
+ try? handle.write(contentsOf: data)
+ } else {
+ try? data.write(to: file)
+ }
+ }
+}
+
+/// Posts one payload of `OversizedPostProbe.targetBytes` to the events service on a session of its own, so what the
+/// service answers a body that size is seen directly rather than through the store and the retry state. The client is
+/// not involved, and need not be running.
+private struct OversizedPostButton: View {
+ @EnvironmentObject private var controller: ClientController
+ @State private var isRunning = false
+ @State private var results: BenchmarkButton.Results?
+
+ var body: some View {
+ // Production answers a body this size with 413 Request Entity Too Large.
+ ActionButton("Post 20 MB", detail: "Expected: HTTP 413", tint: .purple, id: "oversized_post_button") { run() }
+ .disabled(isRunning || controller.configuration.isMissingMobileKey)
+ .sheet(item: $results) { BenchmarkResultsView(results: $0) }
+ }
+
+ private func run() {
+ isRunning = true
+ let controller = self.controller
+ let configuration = controller.configuration
+ controller.result = "Posting 20 MB…"
+ Task {
+ let report = await OversizedPostProbe.run(configuration: configuration)
+ print("oversized post\n\(report)")
+ controller.result = "Oversized post finished"
+ results = BenchmarkButton.Results(title: "Oversized post", text: report)
+ isRunning = false
+ }
+ }
+}
+
+/// Monospaced and scrollable both ways, because the tables are aligned in columns wider than a phone.
+private struct BenchmarkResultsView: View {
+ let results: BenchmarkButton.Results
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ ScrollView([.vertical, .horizontal]) {
+ Text(results.text)
+ .font(.caption2.monospaced())
+ .fixedSize()
+ .selectableWherePossible()
+ .padding()
+ }
+ .navigationTitle(results.title)
+ .inlineTitle()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Close") { dismiss() }
+ }
+ #if os(iOS) || os(macOS)
+ ToolbarItem(placement: .primaryAction) {
+ Button("Copy") { copy(results.text) }
+ }
+ #endif
+ }
+ }
+ #if os(macOS)
+ .frame(minWidth: 640, minHeight: 520)
+ #endif
+ }
+
+ #if os(iOS) || os(macOS)
+ private func copy(_ text: String) {
+ #if os(macOS)
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ #else
+ UIPasteboard.general.string = text
+ #endif
+ }
+ #endif
+}
+#endif
+
+// MARK: - Building blocks
+
+/// Controls side by side, or one under another where the row is too narrow for them, as on a watch.
+private struct ButtonRow: View {
+ @ViewBuilder let content: () -> Content
+
+ var body: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 8) { content() }
+ VStack(alignment: .leading, spacing: 6) { content() }
+ }
+ }
+}
+
+/// A short, filled button. Filled buttons also keep a row's taps apart: in a form row, plain buttons side by side all
+/// fire together.
+private struct ActionButton: View {
+ let title: String
+ let detail: String?
+ let tint: Color
+ let id: String
+ let action: () -> Void
+
+ init(_ title: String, detail: String? = nil, tint: Color = .accentColor, id: String,
+ action: @escaping () -> Void) {
+ self.title = title
+ self.detail = detail
+ self.tint = tint
+ self.id = id
+ self.action = action
+ }
+
+ var body: some View {
+ Button(action: action) {
+ VStack(spacing: 1) {
+ Text(title)
+ if let detail {
+ Text(detail).font(.caption2)
+ }
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(tint)
+ .smallControl()
+ .accessibilityIdentifier(id)
+ }
+}
+
+private extension View {
+ /// Keys are typed exactly: no capitalization or correction.
+ func plainKeyEntry() -> some View {
+ #if os(macOS)
+ return autocorrectionDisabled()
+ #else
+ return autocorrectionDisabled().textInputAutocapitalization(.never)
+ #endif
+ }
+
+ /// Selectable where the platform lets text be selected, so a path can be copied.
+ func selectableWherePossible() -> some View {
+ #if os(iOS) || os(macOS)
+ return textSelection(.enabled)
+ #else
+ return self
+ #endif
+ }
+
+ func segmentedWherePossible() -> some View {
+ #if os(watchOS)
+ return self
+ #else
+ return pickerStyle(.segmented)
+ #endif
+ }
+
+ func smallControl() -> some View {
+ #if os(tvOS)
+ return self
+ #else
+ return controlSize(.small)
+ #endif
+ }
+
+ func inlineTitle() -> some View {
+ #if os(iOS) || os(watchOS)
+ return navigationBarTitleDisplayMode(.inline)
+ #else
+ return self
+ #endif
+ }
+
+ /// Rows only as tall as what is in them, and sections close together.
+ @ViewBuilder
+ func compactForm() -> some View {
+ #if os(macOS)
+ formStyle(.grouped)
+ #elseif os(iOS)
+ if #available(iOS 17.0, *) {
+ environment(\.defaultMinListRowHeight, 0).listSectionSpacing(.compact)
+ } else {
+ environment(\.defaultMinListRowHeight, 0)
+ }
+ #else
+ self
+ #endif
+ }
+}
diff --git a/TestApp/Sources/EventPersistenceBenchmarkRunner.swift b/TestApp/Sources/EventPersistenceBenchmarkRunner.swift
new file mode 100644
index 000000000..394f47990
--- /dev/null
+++ b/TestApp/Sources/EventPersistenceBenchmarkRunner.swift
@@ -0,0 +1,435 @@
+#if DEBUG
+import Foundation
+import OSLog
+@testable import LaunchDarkly
+
+/// The Android test app's `EventPersistenceBenchmarkRunner`, for the Benchmark button: the same five sections, contexts
+/// and table layout, measured against this SDK's `OutboundEventBuffer` and `EventStore` rather than a client.
+///
+/// Those are internal to the SDK, hence `@testable`, which only a Debug build allows. The harness is the XCTest
+/// `EventPersistenceBenchmark`'s rather than Android's: a smaller warmup, since Swift is compiled ahead of time, and a
+/// tenth of the iterations on a watch. Two rows differ from Android's by necessity. Serializing goes through
+/// `EventJSONWriter`, whose context cache is part of the path every event takes, so a context encoded again is a hit.
+/// And there is no table for hashing a context, since `LDContext` is not `Hashable`.
+///
+/// It runs in the app's own process beside a live client, in a Debug build, so read it as ratios between rows.
+final class EventPersistenceBenchmarkRunner {
+ private static let flagKey = "benchmark-flag"
+ private static let creationDate = Date(timeIntervalSince1970: 1_700_000_000)
+ private static let serializedEvent = Data(#"{"kind":"feature","key":"benchmark-flag","value":true,"default":false,"variation":1,"version":7,"creationDate":1740000000000}"#.utf8)
+ private static let rounds = 5
+ private static let bulkSizes = [100, 1_000, 10_000]
+
+ private let scratch: URL
+ private let progress: (String) -> Void
+ private var output = ""
+ private var folders = 0
+
+ /// Consumes results so that the optimizer cannot prove the work is unused and delete it.
+ private var sink = 0
+ /// Holds an allocation so that it genuinely exists.
+ private var escaped: AnyObject?
+
+ private init(scratch: URL, progress: @escaping (String) -> Void) {
+ self.scratch = scratch
+ self.progress = progress
+ }
+
+ /// Runs every section, which takes from several seconds to a few minutes. Never on the main thread.
+ ///
+ /// - Parameters:
+ /// - scratch: a directory for the stores it measures, emptied before and after
+ /// - progress: told the title of each section as it starts, on the thread running the benchmark
+ /// - Returns: the tables, headed by a line describing the device
+ static func run(scratch: URL, progress: @escaping (String) -> Void) throws -> String {
+ let runner = EventPersistenceBenchmarkRunner(scratch: scratch, progress: progress)
+ try? FileManager.default.removeItem(at: scratch)
+ defer { try? FileManager.default.removeItem(at: scratch) }
+ try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
+
+ runner.output = "device=\(machine()) os=\(ProcessInfo.processInfo.operatingSystemVersionString)\n"
+ try runner.encodingCostByContextShape()
+ try runner.recordingCostPerCall()
+ try runner.commitPointOutliers()
+ runner.contextComparisonCost()
+ runner.summarySerializationCost()
+ return runner.output
+ }
+
+ // MARK: Sections
+
+ private func encodingCostByContextShape() throws {
+ progress("encoding cost by context shape")
+ var building: [(String, Double)] = []
+ var serializing: [(String, Double)] = []
+
+ for shape in ContextShape.allCases {
+ let context = try Self.makeContext(shape, key: "benchmark-key")
+
+ building.append((shape.rawValue, measure(iterations: 200_000) {
+ escaped = Self.featureEvent(context, trackEvents: true)
+ }))
+
+ for privacy in PrivacyShape.all {
+ let writer = EventJSONWriter(allAttributesPrivate: privacy.allAttributesPrivate,
+ globalPrivateAttributes: privacy.privateAttributes)
+ guard let sample = writer.encode(Self.featureEvent(context, trackEvents: true))
+ else { throw BenchmarkError("the corpus produced an event that will not serialize") }
+
+ let nanos = measure(iterations: 50_000) {
+ sink &+= writer.encode(Self.featureEvent(context, trackEvents: true))?.count ?? 0
+ }
+ serializing.append(("\(shape.rawValue), \(privacy.name), \(sample.count) bytes", nanos))
+ }
+ }
+
+ report("building one feature event, no serialization", building)
+ report("serializing one feature event", serializing)
+ }
+
+ private func recordingCostPerCall() throws {
+ progress("recording cost per call")
+ let buffer = OutboundEventBuffer(config: Self.config(PrivacyShape.none), encoding: .handWritten)
+ let store = EventStore(directory: try newFolder(), capacity: .max, logger: .disabled)
+ let context = try Self.makeContext(.stub, key: "benchmark-key")
+ let untracked = Self.flag(Self.flagKey, trackEvents: false)
+ let tracked = Self.flag(Self.flagKey, trackEvents: true)
+
+ let summaryOnly = measure(iterations: 200_000) {
+ _ = buffer.summarize(flagKey: Self.flagKey, reportedValue: true, featureFlag: untracked, defaultValue: false,
+ context: context)
+ }
+
+ let fullEvent = measure(iterations: 50_000) {
+ _ = buffer.summarize(flagKey: Self.flagKey, reportedValue: true, featureFlag: tracked, defaultValue: false,
+ context: context)
+ if let encoded = buffer.encode(Self.featureEvent(context, trackEvents: true)) {
+ _ = store.stage(encoded)
+ sink &+= encoded.count
+ }
+ }
+
+ let commitPoint = measure(iterations: 20_000) {
+ _ = buffer.summarize(flagKey: Self.flagKey, reportedValue: true, featureFlag: untracked, defaultValue: false,
+ context: context)
+ let custom = CustomEvent(key: "benchmark-event", context: context, data: nil, creationDate: Self.creationDate)
+ if let encoded = buffer.encode(custom) {
+ _ = store.stage(encoded)
+ sink &+= encoded.count
+ }
+ for summary in buffer.takeSummaries() {
+ if let encoded = buffer.encodeSummary(summary) {
+ _ = store.stage(encoded, bypassingCapacity: true)
+ sink &+= encoded.count
+ }
+ }
+ store.commit()
+ }
+
+ report("recording, per call", [
+ ("evaluation, summary only", summaryOnly),
+ ("evaluation, full event", fullEvent),
+ ("evaluation + track (commit point)", commitPoint)
+ ])
+ }
+
+ private func commitPointOutliers() throws {
+ let buffer = OutboundEventBuffer(config: Self.config(PrivacyShape.none), encoding: .handWritten)
+ let context = try Self.makeContext(.stub, key: "benchmark-key")
+ let tracked = Self.flag(Self.flagKey, trackEvents: true)
+
+ // A store of its own for each size and each row, so that a log inherited from an earlier measurement does not
+ // charge the next one for the file it had already grown.
+ for calls in Self.bulkSizes {
+ progress("commit point outliers, \(count(calls)) calls")
+ let committing = EventStore(directory: try newFolder(), capacity: .max, logger: .disabled)
+ let staging = EventStore(directory: try newFolder(), capacity: .max, logger: .disabled)
+
+ let commits = measureDistribution(iterations: calls) {
+ _ = committing.stage(Self.serializedEvent)
+ committing.commit()
+ }
+ let stagedOnly = measureDistribution(iterations: calls) {
+ _ = buffer.summarize(flagKey: Self.flagKey, reportedValue: true, featureFlag: tracked, defaultValue: false,
+ context: context)
+ if let encoded = buffer.encode(Self.featureEvent(context, trackEvents: true)) {
+ _ = staging.stage(encoded)
+ sink &+= encoded.count
+ }
+ }
+
+ reportDistribution("one call, \(count(calls)) of them, on the caller's thread", [
+ ("track, commits on this thread", commits),
+ ("evaluation, staged only", stagedOnly)
+ ])
+ }
+ }
+
+ private func contextComparisonCost() {
+ progress("context comparison cost")
+ var comparisons: [(String, Double)] = []
+
+ for shape in ContextShape.allCases {
+ guard let context = try? Self.makeContext(shape, key: "benchmark-key"),
+ let rebuilt = try? Self.makeContext(shape, key: "benchmark-key"),
+ context == rebuilt
+ else {
+ // Unequal, the figures below would measure an early exit rather than a comparison.
+ comparisons.append(("\(shape.rawValue): rebuilt context is not equal", 0))
+ continue
+ }
+ let sameInstance = context
+
+ comparisons.append(("same instance, \(shape.rawValue)", measure(iterations: 500_000) {
+ sink &+= context == sameInstance ? 1 : 0
+ }))
+ comparisons.append(("independently built, \(shape.rawValue)", measure(iterations: 200_000) {
+ sink &+= context == rebuilt ? 1 : 0
+ }))
+ }
+
+ report("comparing two equal contexts", comparisons)
+ }
+
+ private func summarySerializationCost() {
+ progress("summary serialization cost")
+ guard let context = try? Self.makeContext(.stub, key: "benchmark-key")
+ else { return }
+ var results: [(String, Double)] = []
+
+ for flagCount in [1, 10, 50] {
+ let buffer = OutboundEventBuffer(config: Self.config(PrivacyShape.none), encoding: .handWritten)
+ let flags = (0.. LDContext {
+ switch shape {
+ case .keyOnly:
+ return try LDContextBuilder(key: key).build().get()
+ case .stub:
+ return try stubContext(key)
+ case .wide:
+ var builder = LDContextBuilder(key: key)
+ builder.name("Wide")
+ for index in 0..<20 {
+ _ = builder.trySetValue("attribute\(index)", .string("value\(index)"))
+ }
+ return try builder.build().get()
+ case .nested:
+ var builder = LDContextBuilder(key: key)
+ builder.name("Nested")
+ _ = builder.trySetValue("address", ["street": "1 Main St", "city": "Springfield", "geo": ["lat": 1.5, "lon": -2.5]])
+ _ = builder.trySetValue("tags", ["a", "b", "c", "d", "e"])
+ return try builder.build().get()
+ case .multi:
+ var device = LDContextBuilder(key: "device-\(key)")
+ device.kind("device")
+ _ = device.trySetValue("os", ["name": "iOS", "version": 18])
+ var builder = LDMultiContextBuilder()
+ builder.addContext(try stubContext(key))
+ builder.addContext(try device.build().get())
+ return try builder.build().get()
+ }
+ }
+
+ /// Nine attributes, the same nine as the Android runner's stub context.
+ private static func stubContext(_ key: String) throws -> LDContext {
+ var builder = LDContextBuilder(key: key)
+ builder.name("Stub Context")
+ _ = builder.trySetValue("firstName", "Ada")
+ _ = builder.trySetValue("lastName", "Lovelace")
+ _ = builder.trySetValue("email", "ada@example.com")
+ _ = builder.trySetValue("country", "US")
+ _ = builder.trySetValue("age", 36)
+ _ = builder.trySetValue("score", 12.5)
+ _ = builder.trySetValue("verified", true)
+ _ = builder.trySetValue("plan", "enterprise")
+ return try builder.build().get()
+ }
+
+ private struct PrivacyShape {
+ let name: String
+ let allAttributesPrivate: Bool
+ let privateAttributes: [Reference]
+
+ static let none = PrivacyShape(name: "no redaction", allAttributesPrivate: false, privateAttributes: [])
+ static let all = [
+ none,
+ PrivacyShape(name: "1 global private", allAttributesPrivate: false, privateAttributes: [Reference("email")]),
+ PrivacyShape(name: "all private", allAttributesPrivate: true, privateAttributes: [])
+ ]
+ }
+
+ private static func config(_ privacy: PrivacyShape) -> LDConfig {
+ var config = LDConfig(mobileKey: "benchmark", autoEnvAttributes: .disabled)
+ config.eventCapacity = .max
+ config.allContextAttributesPrivate = privacy.allAttributesPrivate
+ config.privateContextAttributes = privacy.privateAttributes
+ return config
+ }
+
+ private static func flag(_ key: String, trackEvents: Bool) -> FeatureFlag {
+ FeatureFlag(flagKey: key, value: true, variation: 1, flagVersion: 7, trackEvents: trackEvents)
+ }
+
+ private static func featureEvent(_ context: LDContext, trackEvents: Bool) -> FeatureEvent {
+ FeatureEvent(key: flagKey, context: context, value: true, defaultValue: false,
+ featureFlag: flag(flagKey, trackEvents: trackEvents), includeReason: false, isDebug: false,
+ creationDate: creationDate)
+ }
+
+ // MARK: Harness
+
+ /// Times `body`, returning nanoseconds per iteration: warmed, then the fastest of several rounds, since a
+ /// descheduled thread or a page fault can only ever make a round slower.
+ private func measure(iterations: Int, _ body: () -> Void) -> Double {
+ #if os(watchOS)
+ let measuredIterations = max(1, iterations / 10)
+ #else
+ let measuredIterations = iterations
+ #endif
+
+ for _ in 0.. Void) -> Distribution {
+ body()
+ var samples = [Double](repeating: 0, count: iterations)
+ for index in 0.. URL {
+ let folder = scratch.appendingPathComponent("store-\(folders)", isDirectory: true)
+ folders += 1
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ return folder
+ }
+
+ /// The hardware model, such as `iPhone17,1`. A simulator reports only the host's architecture, so it is named for
+ /// the device it simulates instead.
+ private static func machine() -> String {
+ if let simulated = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] {
+ return "\(simulated) (simulator)"
+ }
+ var system = utsname()
+ uname(&system)
+ return withUnsafeBytes(of: &system.machine) { bytes in
+ String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
+ }
+ }
+
+ private func column(_ title: String) -> String {
+ String(repeating: " ", count: max(0, 11 - title.count)) + title
+ }
+
+ private func count(_ value: Int) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.locale = Locale(identifier: "en_US")
+ return formatter.string(from: NSNumber(value: value)) ?? String(value)
+ }
+
+ private func format(_ nanoseconds: Double) -> String {
+ if nanoseconds >= 1_000_000 {
+ return String(format: "%8.2f ms", nanoseconds / 1_000_000)
+ }
+ if nanoseconds >= 1_000 {
+ return String(format: "%8.2f µs", nanoseconds / 1_000)
+ }
+ return String(format: "%8.1f ns", nanoseconds)
+ }
+}
+
+private struct BenchmarkError: LocalizedError {
+ let errorDescription: String?
+
+ init(_ description: String) {
+ errorDescription = description
+ }
+}
+#endif
diff --git a/TestApp/Sources/EventStorageInspector.swift b/TestApp/Sources/EventStorageInspector.swift
new file mode 100644
index 000000000..539e9a871
--- /dev/null
+++ b/TestApp/Sources/EventStorageInspector.swift
@@ -0,0 +1,123 @@
+import CryptoKit
+import Foundation
+
+/// What the SDK has left in this app's event directory: the logs live processes append to and the batches waiting to
+/// be delivered.
+///
+/// Read from the file names and sizes alone, and never opened, so looking cannot change what it looks at: a lock taken
+/// to see whether a log is held would stop another instance's recovery from claiming it.
+@MainActor
+final class EventStorageInspector: ObservableObject {
+ struct Entry: Identifiable, Equatable {
+ enum Kind: String {
+ /// `pending-`: a log a process appends to, or one a dead process left for recovery.
+ case log
+ /// `new-`: a log being created.
+ case opening
+ /// `ready-.`: a batch closed for delivery.
+ case batch
+ case other
+ }
+
+ let name: String
+ let kind: Kind
+ let bytes: Int
+ let modified: Date?
+ /// What a batch's name says it holds.
+ let events: Int?
+
+ var id: String { name }
+ }
+
+ @Published private(set) var entries: [Entry] = []
+ @Published private(set) var directoryExists = false
+
+ let directory: URL
+ private var timer: Timer?
+
+ init(mobileKey: String) {
+ directory = Self.directory(mobileKey: mobileKey)
+ }
+
+ var summary: String {
+ guard directoryExists
+ else { return "Nothing stored yet" }
+ let logs = entries.filter { $0.kind == .log }.count
+ let batches = entries.filter { $0.kind == .batch }
+ let events = batches.compactMap(\.events).reduce(0, +)
+ return "\(logs) log(s), \(batches.count) batch(es) holding \(events) event(s)"
+ }
+
+ func startWatching() {
+ refresh()
+ guard timer == nil
+ else { return }
+ timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.refresh() }
+ }
+ }
+
+ func stopWatching() {
+ timer?.invalidate()
+ timer = nil
+ }
+
+ func refresh() {
+ let keys: [URLResourceKey] = [.fileSizeKey, .contentModificationDateKey]
+ guard let contents = try? FileManager.default.contentsOfDirectory(at: directory,
+ includingPropertiesForKeys: keys,
+ options: [.skipsHiddenFiles])
+ else {
+ directoryExists = false
+ entries = []
+ return
+ }
+ directoryExists = true
+ entries = contents.map { url in
+ let values = try? url.resourceValues(forKeys: Set(keys))
+ let name = url.lastPathComponent
+ return Entry(name: name, kind: Self.kind(of: name), bytes: values?.fileSize ?? 0,
+ modified: values?.contentModificationDate, events: Self.events(inBatchNamed: name))
+ }
+ .sorted { ($0.modified ?? .distantPast, $0.name) < ($1.modified ?? .distantPast, $1.name) }
+ }
+
+ private static func kind(of name: String) -> Entry.Kind {
+ if name.hasPrefix("pending-") { return .log }
+ if name.hasPrefix("new-") { return .opening }
+ if name.hasPrefix("ready-") { return .batch }
+ return .other
+ }
+
+ private static func events(inBatchNamed name: String) -> Int? {
+ guard name.hasPrefix("ready-")
+ else { return nil }
+ let fields = name.dropFirst("ready-".count).split(separator: ".", omittingEmptySubsequences: false)
+ return fields.count > 1 ? Int(fields[1]) : nil
+ }
+
+ /// Where the SDK keeps events for this application and mobile key: the same path `EventDirectory.defaultUrl`
+ /// works out, which is internal to the SDK.
+ private static func directory(mobileKey: String) -> URL {
+ #if os(tvOS)
+ let searchPath = FileManager.SearchPathDirectory.cachesDirectory
+ #else
+ let searchPath = FileManager.SearchPathDirectory.applicationSupportDirectory
+ #endif
+ let root = FileManager.default.urls(for: searchPath, in: .userDomainMask).first
+ ?? FileManager.default.temporaryDirectory
+ var keyComponent = hash(mobileKey)
+ if let bundleId = Bundle.main.bundleIdentifier {
+ keyComponent = "\(hash(bundleId)).\(keyComponent)"
+ }
+ return root
+ .appendingPathComponent("com.launchdarkly.events", isDirectory: true)
+ .appendingPathComponent(keyComponent, isDirectory: true)
+ }
+
+ private static func hash(_ value: String) -> String {
+ Data(SHA256.hash(data: Data(value.utf8))).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ }
+}
diff --git a/TestApp/Sources/ExposureCountingHook.swift b/TestApp/Sources/ExposureCountingHook.swift
new file mode 100644
index 000000000..43f0aabed
--- /dev/null
+++ b/TestApp/Sources/ExposureCountingHook.swift
@@ -0,0 +1,53 @@
+import Foundation
+import LaunchDarkly
+
+/// Counts the evaluation series stages it observes, so the app can show what exposure deduplication does.
+/// Deduplication is configured at registration by wrapping this hook in a `DedupingHook`, the same way an application
+/// would wrap any other hook.
+///
+/// Deduplication skips the whole series, so both counts stay equal, and both stop climbing while repeated evaluations
+/// resolve to the same result.
+final class ExposureCountingHook: Hook {
+ private let label: String
+ private let onStage: () -> Void
+ private let lock = NSLock()
+ private var befores = 0
+ private var afters = 0
+
+ /// - Parameters:
+ /// - label: a name for this hook, shown in the app's dedupe status.
+ /// - onStage: called after each stage, on whichever thread evaluated, so the app can refresh its display.
+ init(label: String, onStage: @escaping () -> Void) {
+ self.label = label
+ self.onStage = onStage
+ }
+
+ func metadata() -> Metadata {
+ Metadata(name: label)
+ }
+
+ func beforeEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData) -> EvaluationSeriesData {
+ lock.lock()
+ befores += 1
+ lock.unlock()
+ onStage()
+ return seriesData
+ }
+
+ func afterEvaluation(seriesContext: EvaluationSeriesContext,
+ seriesData: EvaluationSeriesData,
+ evaluationDetail: LDEvaluationDetail) -> EvaluationSeriesData {
+ lock.lock()
+ afters += 1
+ lock.unlock()
+ onStage()
+ return seriesData
+ }
+
+ /// This hook's window and how many evaluations have reached each stage, as `fast 5s: 3/3`.
+ func status(window: TimeInterval) -> String {
+ lock.lock()
+ defer { lock.unlock() }
+ return "\(label) \(Int(window))s: \(befores)/\(afters)"
+ }
+}
diff --git a/TestApp/Sources/InstanceLauncher.swift b/TestApp/Sources/InstanceLauncher.swift
new file mode 100644
index 000000000..a5d459e55
--- /dev/null
+++ b/TestApp/Sources/InstanceLauncher.swift
@@ -0,0 +1,57 @@
+import Foundation
+#if os(macOS)
+import AppKit
+#endif
+
+/// Starts another instance of this app on a Mac, so several processes share one event directory.
+///
+/// Each instance has its own open log; a batch any of them closes can be delivered by any other, and a log one left
+/// behind when it was killed is recovered by the next client that starts. `scripts/launch-instances.sh` does the same
+/// from a terminal.
+enum InstanceLauncher {
+ static var isAvailable: Bool {
+ #if os(macOS) || targetEnvironment(macCatalyst)
+ return true
+ #else
+ return false
+ #endif
+ }
+
+ static var processId: Int32 {
+ ProcessInfo.processInfo.processIdentifier
+ }
+
+ /// Returns a message describing what happened, for the result line.
+ static func launchAnother() -> String {
+ #if os(macOS)
+ let configuration = NSWorkspace.OpenConfiguration()
+ configuration.createsNewApplicationInstance = true
+ configuration.arguments = forwardedArguments
+ NSWorkspace.shared.openApplication(at: Bundle.main.bundleURL, configuration: configuration)
+ return "Launching another instance"
+ #elseif targetEnvironment(macCatalyst)
+ // Neither NSWorkspace nor Process is available to a Catalyst app; open(1) is what starts a new instance.
+ let arguments = ["/usr/bin/open", "-n", Bundle.main.bundlePath]
+ + (forwardedArguments.isEmpty ? [] : ["--args"] + forwardedArguments)
+ var argv = arguments.map { strdup($0) } + [nil]
+ defer { argv.forEach { free($0) } }
+ var pid: pid_t = 0
+ let status = posix_spawn(&pid, arguments[0], nil, nil, &argv, environ)
+ return status == 0 ? "Launching another instance" : "Could not launch another instance: \(String(cString: strerror(status)))"
+ #else
+ return "Only a Mac runs several instances of an app"
+ #endif
+ }
+
+ /// The `-LDMobileKey` and `-LDEnvironment` overrides this instance was launched with, so the next one talks to the
+ /// same environment.
+ private static var forwardedArguments: [String] {
+ var forwarded: [String] = []
+ for key in ["LDMobileKey", "LDEnvironment"] {
+ if let value = UserDefaults.standard.volatileDomain(forName: UserDefaults.argumentDomain)[key] as? String {
+ forwarded += ["-\(key)", value]
+ }
+ }
+ return forwarded
+ }
+}
diff --git a/TestApp/Sources/LDTestApp.swift b/TestApp/Sources/LDTestApp.swift
new file mode 100644
index 000000000..f6a8ea875
--- /dev/null
+++ b/TestApp/Sources/LDTestApp.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+
+@main
+struct LDTestApp: App {
+ @StateObject private var controller = ClientController()
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environmentObject(controller)
+ .onAppear { controller.start() }
+ }
+ #if os(macOS)
+ .defaultSize(width: 520, height: 760)
+ #endif
+ }
+}
diff --git a/TestApp/Sources/OversizedPostProbe.swift b/TestApp/Sources/OversizedPostProbe.swift
new file mode 100644
index 000000000..34fe1e354
--- /dev/null
+++ b/TestApp/Sources/OversizedPostProbe.swift
@@ -0,0 +1,89 @@
+import Foundation
+import LaunchDarkly
+
+/// Posts one analytics payload of `targetBytes` straight to the events service, with the headers the SDK's sender
+/// uses, and reports what came back. Through a session of its own rather than the SDK, so neither the event store, the
+/// batch size limit nor the retry state is involved: one request, one answer.
+///
+/// The events are valid custom events, so a service that takes the payload records them.
+enum OversizedPostProbe {
+ static let targetBytes = 20 * 1024 * 1024
+
+ private static let path = "mobile/events/bulk"
+ private static let eventDataChars = 900
+ private static let responseBodyShown = 2_000
+
+ #if os(macOS)
+ private static let systemName = "macOS"
+ #elseif os(tvOS)
+ private static let systemName = "tvOS"
+ #elseif os(watchOS)
+ private static let systemName = "watchOS"
+ #else
+ private static let systemName = "iOS"
+ #endif
+
+ static func run(configuration: TestAppConfiguration) async -> String {
+ var config = LDConfig(mobileKey: configuration.mobileKey, autoEnvAttributes: .disabled)
+ configuration.applyEndpoints(to: &config)
+ let url = config.eventsUrl.appendingPathComponent(path)
+ let payloadId = UUID().uuidString
+ let creationDate = Int64(Date().timeIntervalSince1970 * 1000)
+ let event = Data(("{\"kind\":\"custom\",\"key\":\"oversized-post-probe\",\"creationDate\":\(creationDate),"
+ + "\"context\":{\"kind\":\"user\",\"key\":\"oversized-post-probe\"},"
+ + "\"data\":\"\(String(repeating: "x", count: eventDataChars))\"}").utf8)
+ let eventCount = (targetBytes - 1) / (event.count + 1)
+ let body = makeBody(event: event, count: eventCount)
+
+ var report = "POST \(url.absoluteString)\n\(eventCount) events, \(body.count.formatted()) bytes\n"
+ + "Payload ID \(payloadId)\n\n"
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("api_key \(configuration.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")
+ request.setValue("4", forHTTPHeaderField: "X-LaunchDarkly-Event-Schema")
+ request.setValue(payloadId, forHTTPHeaderField: "X-LaunchDarkly-Payload-ID")
+
+ let sessionConfiguration = URLSessionConfiguration.ephemeral
+ // Long enough for 20 MB to go up over a slow connection before the answer.
+ sessionConfiguration.timeoutIntervalForRequest = 180
+ let session = URLSession(configuration: sessionConfiguration)
+ defer { session.finishTasksAndInvalidate() }
+
+ let started = Date()
+ do {
+ let (data, response) = try await session.upload(for: request, from: body)
+ report += String(format: "Answered after %.1f s\n\n", Date().timeIntervalSince(started))
+ guard let http = response as? HTTPURLResponse
+ else { return report + "Not an HTTP response: \(response)\n" }
+ report += "HTTP \(http.statusCode) \(HTTPURLResponse.localizedString(forStatusCode: http.statusCode))\n"
+ for (name, value) in http.allHeaderFields.map({ ("\($0.key)", "\($0.value)") }).sorted(by: { $0.0 < $1.0 }) {
+ report += "\(name): \(value)\n"
+ }
+ if !data.isEmpty {
+ let text = String(decoding: data.prefix(responseBodyShown), as: UTF8.self)
+ report += "\n\(text)\(data.count > responseBodyShown ? "…" : "")\n"
+ }
+ } catch {
+ report += String(format: "No answer after %.1f s: %@\n", Date().timeIntervalSince(started),
+ String(describing: error))
+ }
+ return report
+ }
+
+ private static func makeBody(event: Data, count: Int) -> Data {
+ var body = Data(capacity: 2 + count * event.count + max(0, count - 1))
+ body.append(UInt8(ascii: "["))
+ for index in 0.. 0 {
+ body.append(UInt8(ascii: ","))
+ }
+ body.append(event)
+ }
+ body.append(UInt8(ascii: "]"))
+ return body
+ }
+}
diff --git a/TestApp/Sources/Scenarios.swift b/TestApp/Sources/Scenarios.swift
new file mode 100644
index 000000000..aca51cf09
--- /dev/null
+++ b/TestApp/Sources/Scenarios.swift
@@ -0,0 +1,115 @@
+import Darwin
+import Foundation
+import LaunchDarkly
+
+/// The event-loss and stress scenarios, the same ones the Android test app has.
+///
+/// The three exits record the same pair -- an evaluation, which is the exposure, and a track standing in for the error
+/// an application reports just before it dies -- and then end the process a different way. What differs is how much
+/// opportunity the SDK gets to deliver the pair first, so together they separate "delivered in time" from "was already
+/// on disk".
+@MainActor
+enum Scenarios {
+ static let defaultKillFlag = "kill-flag"
+ static let overRefreshFlag = "trackevents-test"
+ private static let errorEventKey = "$ld:telemetry:error"
+
+ /// Enough evaluations past capacity for the average to settle, and few enough that a slow device finishes the tap
+ /// promptly.
+ private static let overCapacityEvaluations = 2000
+
+ /// A batch holds at most 2 MiB, so seven of these fit in one and an eighth does not. Large events rather than many
+ /// small ones, because the event capacity is reached long before small events fill a batch.
+ private static let largeEventDataCharacters = 256 * 1024
+ /// Seven, seven and six: three batches, the last of them not full.
+ private static let largeEvents = 20
+
+ /// Returns false where there is no client, in which case nothing was recorded and ending the process would
+ /// demonstrate nothing.
+ private static func recordExposureAndError(_ client: LDClient?, flagKey: String) -> Bool {
+ guard let client
+ else { return false }
+ _ = client.boolVariation(forKey: flagKey, defaultValue: false)
+ client.track(key: errorEventKey)
+ return true
+ }
+
+ /// Records the pair, asks for a flush, and sends `SIGKILL` five seconds later.
+ ///
+ /// The control: the SDK gets an explicit flush and five seconds of runway, so the pair should arrive on this
+ /// launch. Backgrounding or quitting would run the SDK's own delivery, so this kills the process instead.
+ static func evalTrackFlushThenKill(_ client: LDClient?, flagKey: String) {
+ guard recordExposureAndError(client, flagKey: flagKey)
+ else { return }
+ client?.flush()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
+ kill(getpid(), SIGKILL)
+ }
+ }
+
+ /// Nothing at all between the track and the process dying: no flush, no delay for a timer to fire in, and
+ /// `SIGKILL`, which cannot be caught, so no part of the SDK runs on the way out.
+ ///
+ /// Whether the pair is reported therefore says exactly one thing: whether recording it had already put it on disk.
+ /// Under `.immediate` it should arrive on the next launch, not this one.
+ static func evalTrackThenKillNow(_ client: LDClient?, flagKey: String) {
+ guard recordExposureAndError(client, flagKey: flagKey)
+ else { return }
+ kill(getpid(), SIGKILL)
+ }
+
+ /// The same again, ending in a Swift runtime trap instead of a signal the process never sees.
+ ///
+ /// This is the shape a customer report takes: application code hits a fatal error immediately after reporting it.
+ /// Unlike an uncaught exception on Android, a trap leaves nothing that could safely flush, so this behaves like
+ /// the kill: the pair arrives on the next launch only if it was already on disk.
+ static func evalTrackThenFatalErrorNow(_ client: LDClient?, flagKey: String) {
+ guard recordExposureAndError(client, flagKey: flagKey)
+ else { return }
+ fatalError("Eval+Fatal: deliberate fatalError immediately after track, to test event persistence")
+ }
+
+ /// Reproduces the over-refresh pathology: a UI that re-evaluates one flag far more often than anything about it
+ /// changed, against a flag whose events LaunchDarkly is tracking. Runs on the main thread, because that is where
+ /// the loop it stands in for runs.
+ ///
+ /// The comparison between the two phases is the point rather than either number on its own. The first
+ /// `eventCapacity` evaluations have room in the event store; every one after that is dropped, because nothing
+ /// empties it until a flush. So the two phases costing the same per evaluation means the SDK is serializing events
+ /// it then discards. A cost of a couple of microseconds instead means the flag has event tracking off, and this
+ /// measured the summary counters rather than the thing in question.
+ static func overRefreshEval(_ client: LDClient?, eventCapacity: Int) -> String? {
+ guard let client
+ else { return nil }
+ let evaluate = { _ = client.boolVariation(forKey: overRefreshFlag, defaultValue: false) }
+ let filling = averageMicroseconds(over: eventCapacity, evaluate)
+ let overCapacity = averageMicroseconds(over: overCapacityEvaluations, evaluate)
+ return String(format: "%@: %.0f µs/eval filling %d, %.0f µs/eval over capacity",
+ overRefreshFlag, filling, eventCapacity, overCapacity)
+ }
+
+ /// Records more events than one batch holds, so a flush sends several requests, each with a payload ID of its own.
+ /// Offline, or followed by a kill, it leaves several `ready-` files on disk for the next launch to deliver. Each
+ /// event's index is in its data, so the dashboard shows whether all of them arrived and none twice.
+ static func fillBatches(_ client: LDClient?) -> String? {
+ guard let client
+ else { return nil }
+ let padding = LDValue.string(String(repeating: "x", count: largeEventDataCharacters))
+ for index in 1...largeEvents {
+ client.track(key: "fill-batches", data: [
+ "index": .number(Double(index)),
+ "of": .number(Double(largeEvents)),
+ "padding": padding,
+ ])
+ }
+ return "Tracked \(largeEvents) × \(largeEventDataCharacters / 1024) KiB: 3 batches of at most 2 MiB"
+ }
+
+ private static func averageMicroseconds(over iterations: Int, _ body: () -> Void) -> Double {
+ let start = DispatchTime.now().uptimeNanoseconds
+ for _ in 0.. String {
+ if let overridden = UserDefaults.standard.string(forKey: key), !overridden.isEmpty {
+ return overridden
+ }
+ return (Bundle.main.object(forInfoDictionaryKey: key) as? String)?
+ .trimmingCharacters(in: .whitespaces) ?? ""
+ }
+}
diff --git a/TestApp/TestApp.xcodeproj/project.pbxproj b/TestApp/TestApp.xcodeproj/project.pbxproj
new file mode 100644
index 000000000..22fde90c9
--- /dev/null
+++ b/TestApp/TestApp.xcodeproj/project.pbxproj
@@ -0,0 +1,645 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ CEDEE24D7B05CFA68043C693 /* LaunchDarkly in Frameworks */ = {isa = PBXBuildFile; productRef = CC78066B0894655CFE7EF2E2 /* LaunchDarkly */; };
+ 0283C1935993A349063B821F /* LaunchDarkly in Frameworks */ = {isa = PBXBuildFile; productRef = D45F7DDF422A68EA9934637E /* LaunchDarkly */; };
+ 5EA7377F5B5A3ABBB19CBF1C /* LaunchDarkly in Frameworks */ = {isa = PBXBuildFile; productRef = 65F9D2CD78AF4A50CB539068 /* LaunchDarkly */; };
+ 0E6A203CB3A6CF6D9ED9E944 /* LaunchDarkly in Frameworks */ = {isa = PBXBuildFile; productRef = 767700E143811681E3495293 /* LaunchDarkly */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ A84FA4263635512BA8514F1E /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 156FAEEAB0F517E2DBF949EC /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3AE91B39E2A33B69F74728B6 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 0C85250F1DA52A5B8267E82B /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ F9FB9D3AAC4422B64C7754B5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 64176E0FC83BAF50A72204A2 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; };
+ BB359E2DE3D9F1D31B4CD0B5 /* Secrets.xcconfig.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = Secrets.xcconfig.example; sourceTree = ""; };
+ 1F0192090B5F433A01497652 /* TestApp.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TestApp.xcconfig; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ FD49639B8541DB45E49ED60C /* Sources */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Sources;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ DB27C76075EF2E4C4401DF96 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ CEDEE24D7B05CFA68043C693 /* LaunchDarkly in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ B6F1150BDFB7C9E3377C6C19 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 0283C1935993A349063B821F /* LaunchDarkly in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7ECF64B78376817C3AECD70D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 5EA7377F5B5A3ABBB19CBF1C /* LaunchDarkly in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 376F05A49E51AA4DAF0BD95E /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 0E6A203CB3A6CF6D9ED9E944 /* LaunchDarkly in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 1D731FD88497105748C07072 = {
+ isa = PBXGroup;
+ children = (
+ 64176E0FC83BAF50A72204A2 /* README.md */,
+ CEA126185FDBD61CE24817FE /* Config */,
+ FD49639B8541DB45E49ED60C /* Sources */,
+ 1FD0D9A4E3DE0C1C5EA81B13 /* Products */,
+ );
+ sourceTree = "";
+ };
+ CEA126185FDBD61CE24817FE /* Config */ = {
+ isa = PBXGroup;
+ children = (
+ 1F0192090B5F433A01497652 /* TestApp.xcconfig */,
+ BB359E2DE3D9F1D31B4CD0B5 /* Secrets.xcconfig.example */,
+ F9FB9D3AAC4422B64C7754B5 /* Info.plist */,
+ );
+ path = Config;
+ sourceTree = "";
+ };
+ 1FD0D9A4E3DE0C1C5EA81B13 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ A84FA4263635512BA8514F1E /* TestApp.app */,
+ 156FAEEAB0F517E2DBF949EC /* TestApp.app */,
+ 3AE91B39E2A33B69F74728B6 /* TestApp.app */,
+ 0C85250F1DA52A5B8267E82B /* TestApp.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 84D61D7CFE5FA5C90D97A83E /* TestApp_iOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = DB5FE6A8432F7F75BD495AF2 /* Build configuration list for PBXNativeTarget "TestApp_iOS" */;
+ buildPhases = (
+ 639683716927051EBD53C18D /* Sources */,
+ DB27C76075EF2E4C4401DF96 /* Frameworks */,
+ B8BB1A0B6ABA49AAA887CA80 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ FD49639B8541DB45E49ED60C /* Sources */,
+ );
+ name = TestApp_iOS;
+ packageProductDependencies = (
+ CC78066B0894655CFE7EF2E2 /* LaunchDarkly */,
+ );
+ productName = TestApp;
+ productReference = A84FA4263635512BA8514F1E /* TestApp.app */;
+ productType = "com.apple.product-type.application";
+ };
+ D800CC93BF96A9977507AE67 /* TestApp_macOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 3A9EB03770AF8593632CC324 /* Build configuration list for PBXNativeTarget "TestApp_macOS" */;
+ buildPhases = (
+ 8A9683CEA77C1E47D3E9E28F /* Sources */,
+ B6F1150BDFB7C9E3377C6C19 /* Frameworks */,
+ D4FA9110A59AEB668DE7B7A7 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ FD49639B8541DB45E49ED60C /* Sources */,
+ );
+ name = TestApp_macOS;
+ packageProductDependencies = (
+ D45F7DDF422A68EA9934637E /* LaunchDarkly */,
+ );
+ productName = TestApp;
+ productReference = 156FAEEAB0F517E2DBF949EC /* TestApp.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 2234797968464E3777ECB3AE /* TestApp_tvOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 2F195802DA2EE9D790C76995 /* Build configuration list for PBXNativeTarget "TestApp_tvOS" */;
+ buildPhases = (
+ 525262BE4BB3A29D4DEFD108 /* Sources */,
+ 7ECF64B78376817C3AECD70D /* Frameworks */,
+ 7C1A73BC7C58DA8AF3BF565D /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ FD49639B8541DB45E49ED60C /* Sources */,
+ );
+ name = TestApp_tvOS;
+ packageProductDependencies = (
+ 65F9D2CD78AF4A50CB539068 /* LaunchDarkly */,
+ );
+ productName = TestApp;
+ productReference = 3AE91B39E2A33B69F74728B6 /* TestApp.app */;
+ productType = "com.apple.product-type.application";
+ };
+ CFDEA1753091DC419A638A15 /* TestApp_watchOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = B930671BAB5AC9482056C72F /* Build configuration list for PBXNativeTarget "TestApp_watchOS" */;
+ buildPhases = (
+ 1C0CFED1A31130CEEE94F011 /* Sources */,
+ 376F05A49E51AA4DAF0BD95E /* Frameworks */,
+ 062D4A8D26CE47BFACCF72A7 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ FD49639B8541DB45E49ED60C /* Sources */,
+ );
+ name = TestApp_watchOS;
+ packageProductDependencies = (
+ 767700E143811681E3495293 /* LaunchDarkly */,
+ );
+ productName = TestApp;
+ productReference = 0C85250F1DA52A5B8267E82B /* TestApp.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ B8C333A5C4527AED582B046D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1600;
+ LastUpgradeCheck = 1600;
+ TargetAttributes = {
+ 84D61D7CFE5FA5C90D97A83E = {
+ CreatedOnToolsVersion = 16.0;
+ };
+ D800CC93BF96A9977507AE67 = {
+ CreatedOnToolsVersion = 16.0;
+ };
+ 2234797968464E3777ECB3AE = {
+ CreatedOnToolsVersion = 16.0;
+ };
+ CFDEA1753091DC419A638A15 = {
+ CreatedOnToolsVersion = 16.0;
+ };
+ };
+ };
+ buildConfigurationList = 01B4EC7354D5A318ABE7615E /* Build configuration list for PBXProject "TestApp" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 1D731FD88497105748C07072;
+ minimizedProjectReferenceProxies = 1;
+ packageReferences = (
+ 57FD389796E8B774B13C6913 /* XCLocalSwiftPackageReference ".." */,
+ );
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 1FD0D9A4E3DE0C1C5EA81B13 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 84D61D7CFE5FA5C90D97A83E /* TestApp_iOS */,
+ D800CC93BF96A9977507AE67 /* TestApp_macOS */,
+ 2234797968464E3777ECB3AE /* TestApp_tvOS */,
+ CFDEA1753091DC419A638A15 /* TestApp_watchOS */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ B8BB1A0B6ABA49AAA887CA80 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ D4FA9110A59AEB668DE7B7A7 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7C1A73BC7C58DA8AF3BF565D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 062D4A8D26CE47BFACCF72A7 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 639683716927051EBD53C18D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 8A9683CEA77C1E47D3E9E28F /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 525262BE4BB3A29D4DEFD108 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 1C0CFED1A31130CEEE94F011 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ B12473776016E24CE0662C99 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 1F0192090B5F433A01497652 /* TestApp.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ D48D2653997FD418B07159B2 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 1F0192090B5F433A01497652 /* TestApp.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ EE103BE432173DAC62E58DF4 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Config/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "LD Test App";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.launchdarkly.sdk.testapp;
+ PRODUCT_NAME = TestApp;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = YES;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 73FA538939665BB8EB4B1A46 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Config/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "LD Test App";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.launchdarkly.sdk.testapp;
+ PRODUCT_NAME = TestApp;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = YES;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ 59CCC786AD507463470F9471 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_IDENTITY = "-";
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Config/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "LD Test App";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.launchdarkly.sdk.testapp.macos;
+ PRODUCT_NAME = TestApp;
+ SDKROOT = macosx;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 3ABB9AD4566257FFA04607F5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_IDENTITY = "-";
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Config/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "LD Test App";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.launchdarkly.sdk.testapp.macos;
+ PRODUCT_NAME = TestApp;
+ SDKROOT = macosx;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ B7A09451475086E76C27A920 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Config/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "LD Test App";
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.launchdarkly.sdk.testapp.tvos;
+ PRODUCT_NAME = TestApp;
+ SDKROOT = appletvos;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TVOS_DEPLOYMENT_TARGET = 16.0;
+ };
+ name = Debug;
+ };
+ 3F0193EF402D521E769566F3 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Config/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "LD Test App";
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.launchdarkly.sdk.testapp.tvos;
+ PRODUCT_NAME = TestApp;
+ SDKROOT = appletvos;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ TVOS_DEPLOYMENT_TARGET = 16.0;
+ };
+ name = Release;
+ };
+ 599E6CE8303977F05EE07DDE /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Config/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "LD Test App";
+ INFOPLIST_KEY_WKApplication = YES;
+ INFOPLIST_KEY_WKWatchOnly = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.launchdarkly.sdk.testapp.watchkitapp;
+ PRODUCT_NAME = TestApp;
+ SDKROOT = watchos;
+ SKIP_INSTALL = NO;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ WATCHOS_DEPLOYMENT_TARGET = 9.0;
+ };
+ name = Debug;
+ };
+ D534BBEFBFB8A1932C9275EF /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Config/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "LD Test App";
+ INFOPLIST_KEY_WKApplication = YES;
+ INFOPLIST_KEY_WKWatchOnly = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.launchdarkly.sdk.testapp.watchkitapp;
+ PRODUCT_NAME = TestApp;
+ SDKROOT = watchos;
+ SKIP_INSTALL = NO;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ WATCHOS_DEPLOYMENT_TARGET = 9.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 01B4EC7354D5A318ABE7615E /* Build configuration list for PBXProject "TestApp" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B12473776016E24CE0662C99 /* Debug */,
+ D48D2653997FD418B07159B2 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ DB5FE6A8432F7F75BD495AF2 /* Build configuration list for PBXNativeTarget "TestApp_iOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ EE103BE432173DAC62E58DF4 /* Debug */,
+ 73FA538939665BB8EB4B1A46 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 3A9EB03770AF8593632CC324 /* Build configuration list for PBXNativeTarget "TestApp_macOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 59CCC786AD507463470F9471 /* Debug */,
+ 3ABB9AD4566257FFA04607F5 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 2F195802DA2EE9D790C76995 /* Build configuration list for PBXNativeTarget "TestApp_tvOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B7A09451475086E76C27A920 /* Debug */,
+ 3F0193EF402D521E769566F3 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ B930671BAB5AC9482056C72F /* Build configuration list for PBXNativeTarget "TestApp_watchOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 599E6CE8303977F05EE07DDE /* Debug */,
+ D534BBEFBFB8A1932C9275EF /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 57FD389796E8B774B13C6913 /* XCLocalSwiftPackageReference ".." */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ..;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ CC78066B0894655CFE7EF2E2 /* LaunchDarkly */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = LaunchDarkly;
+ };
+ D45F7DDF422A68EA9934637E /* LaunchDarkly */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = LaunchDarkly;
+ };
+ 65F9D2CD78AF4A50CB539068 /* LaunchDarkly */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = LaunchDarkly;
+ };
+ 767700E143811681E3495293 /* LaunchDarkly */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = LaunchDarkly;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = B8C333A5C4527AED582B046D /* Project object */;
+}
diff --git a/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_iOS.xcscheme b/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_iOS.xcscheme
new file mode 100644
index 000000000..58e003838
--- /dev/null
+++ b/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_iOS.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_macOS.xcscheme b/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_macOS.xcscheme
new file mode 100644
index 000000000..0a6e81d8a
--- /dev/null
+++ b/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_macOS.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_tvOS.xcscheme b/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_tvOS.xcscheme
new file mode 100644
index 000000000..039367612
--- /dev/null
+++ b/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_tvOS.xcscheme
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_watchOS.xcscheme b/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_watchOS.xcscheme
new file mode 100644
index 000000000..adcbeed42
--- /dev/null
+++ b/TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_watchOS.xcscheme
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TestApp/scripts/launch-instances.sh b/TestApp/scripts/launch-instances.sh
new file mode 100755
index 000000000..3bd87818f
--- /dev/null
+++ b/TestApp/scripts/launch-instances.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Builds the Mac test app and starts several instances of it, which share one event directory.
+#
+# Usage: launch-instances.sh [-c] [count] [-- app arguments...]
+# -c Mac Catalyst rather than native macOS. The two have different bundle identifiers, so different directories.
+# count How many instances to start; 2 by default.
+# Anything after -- is passed to every instance, such as -LDMobileKey mob-... or -LDEnvironment staging.
+set -euo pipefail
+
+catalyst=false
+while getopts "c" option; do
+ case "$option" in
+ c) catalyst=true ;;
+ *) sed -n '2,8p' "$0"; exit 64 ;;
+ esac
+done
+shift $((OPTIND - 1))
+
+count=2
+if [[ $# -gt 0 && "$1" != "--" ]]; then
+ count="$1"
+ shift
+fi
+[[ "${1:-}" == "--" ]] && shift
+
+test_app="$(cd "$(dirname "$0")/.." && pwd)"
+derived_data="$test_app/../build/testapp"
+
+if $catalyst; then
+ scheme=TestApp_iOS
+ destination="platform=macOS,variant=Mac Catalyst"
+ products=Debug-maccatalyst
+else
+ scheme=TestApp_macOS
+ destination="platform=macOS"
+ products=Debug
+fi
+
+xcodebuild build -quiet \
+ -project "$test_app/TestApp.xcodeproj" \
+ -scheme "$scheme" \
+ -destination "$destination" \
+ -derivedDataPath "$derived_data"
+
+app="$derived_data/Build/Products/$products/TestApp.app"
+for _ in $(seq "$count"); do
+ if [[ $# -gt 0 ]]; then
+ open -n "$app" --args "$@"
+ else
+ open -n "$app"
+ fi
+done
+echo "Started $count instance(s) of $app"