From d9540dd7e0f0f6cb220c6524f00fc1731a32846d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 23 Oct 2026 07:40:09 -0700 Subject: [PATCH 1/9] Add an Apple test app for the flag SDK TestApp/ is an Xcode project linking the SDK as a local Swift package, with a target per platform: iOS (also built for Mac Catalyst), native macOS, tvOS and watchOS. It evaluates flags, tracks and identifies, switches event persistence and offline mode, kills the process with events unsent, and shows the event directory as it changes. On a Mac it launches more instances of itself, to exercise persistence shared between processes. CI builds every target. Co-authored-by: Cursor --- .github/actions/build-test-app/action.yml | 37 + .github/workflows/ci.yml | 13 + .gitignore | 3 + TestApp/Config/Info.plist | 10 + TestApp/Config/Secrets.xcconfig.example | 6 + TestApp/Config/TestApp.xcconfig | 7 + TestApp/README.md | 82 +++ TestApp/Sources/ClientController.swift | 199 ++++++ TestApp/Sources/ContentView.swift | 401 +++++++++++ TestApp/Sources/EventStorageInspector.swift | 123 ++++ TestApp/Sources/ExposureCountingHook.swift | 53 ++ TestApp/Sources/InstanceLauncher.swift | 57 ++ TestApp/Sources/LDTestApp.swift | 17 + TestApp/Sources/Scenarios.swift | 115 ++++ TestApp/Sources/TestAppConfiguration.swift | 41 ++ TestApp/TestApp.xcodeproj/project.pbxproj | 645 ++++++++++++++++++ .../xcschemes/TestApp_iOS.xcscheme | 78 +++ .../xcschemes/TestApp_macOS.xcscheme | 78 +++ .../xcschemes/TestApp_tvOS.xcscheme | 76 +++ .../xcschemes/TestApp_watchOS.xcscheme | 76 +++ TestApp/scripts/launch-instances.sh | 53 ++ 21 files changed, 2170 insertions(+) create mode 100644 .github/actions/build-test-app/action.yml create mode 100644 TestApp/Config/Info.plist create mode 100644 TestApp/Config/Secrets.xcconfig.example create mode 100644 TestApp/Config/TestApp.xcconfig create mode 100644 TestApp/README.md create mode 100644 TestApp/Sources/ClientController.swift create mode 100644 TestApp/Sources/ContentView.swift create mode 100644 TestApp/Sources/EventStorageInspector.swift create mode 100644 TestApp/Sources/ExposureCountingHook.swift create mode 100644 TestApp/Sources/InstanceLauncher.swift create mode 100644 TestApp/Sources/LDTestApp.swift create mode 100644 TestApp/Sources/Scenarios.swift create mode 100644 TestApp/Sources/TestAppConfiguration.swift create mode 100644 TestApp/TestApp.xcodeproj/project.pbxproj create mode 100644 TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_iOS.xcscheme create mode 100644 TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_macOS.xcscheme create mode 100644 TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_tvOS.xcscheme create mode 100644 TestApp/TestApp.xcodeproj/xcshareddata/xcschemes/TestApp_watchOS.xcscheme create mode 100755 TestApp/scripts/launch-instances.sh 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..afa263057 --- /dev/null +++ b/TestApp/README.md @@ -0,0 +1,82 @@ +# 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. diff --git a/TestApp/Sources/ClientController.swift b/TestApp/Sources/ClientController.swift new file mode 100644 index 000000000..cac6c26d2 --- /dev/null +++ b/TestApp/Sources/ClientController.swift @@ -0,0 +1,199 @@ +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 + + /// 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.isRestarting = false + self.observeConnection() + 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() + } + + 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..ffb5e2789 --- /dev/null +++ b/TestApp/Sources/ContentView.swift @@ -0,0 +1,401 @@ +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") + } + 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() } + } + } + } + + 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))" + client.observe(key: key, owner: controller) { [weak controller] changed in + Task { @MainActor in + guard let controller, let client = controller.client + else { return } + controller.countEvaluation() + controller.result = "\(changed.key) changed to \(describe(client, changed.key))" + } + } + } + + 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") + } + } +} + +// 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 tint: Color + let id: String + let action: () -> Void + + init(_ title: String, tint: Color = .accentColor, id: String, action: @escaping () -> Void) { + self.title = title + self.tint = tint + self.id = id + self.action = action + } + + var body: some View { + Button(title, action: action) + .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/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/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" From 51b9fb18143f5ccadb6aa71c09bdfcb574e6f855 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 23 Oct 2026 07:53:07 -0700 Subject: [PATCH 2/9] Report a test app client started offline as offline, not timed out Co-authored-by: Cursor --- TestApp/Sources/ClientController.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/TestApp/Sources/ClientController.swift b/TestApp/Sources/ClientController.swift index cac6c26d2..5a7beb345 100644 --- a/TestApp/Sources/ClientController.swift +++ b/TestApp/Sources/ClientController.swift @@ -100,7 +100,12 @@ final class ClientController: ObservableObject { self.config = config self.isRestarting = false self.observeConnection() - self.result = timedOut ? "\(reason); flags did not arrive in time" : reason + // 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 + } } } } From 8234a162478235e8d370220cdceb149b78f5cc2f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 23 Oct 2026 07:53:07 -0700 Subject: [PATCH 3/9] Poll staging on app.ld-stg, mirroring the SDK's production polling host Co-authored-by: Cursor --- TestApp/Sources/TestAppConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestApp/Sources/TestAppConfiguration.swift b/TestApp/Sources/TestAppConfiguration.swift index 89751b596..756e8fbde 100644 --- a/TestApp/Sources/TestAppConfiguration.swift +++ b/TestApp/Sources/TestAppConfiguration.swift @@ -26,7 +26,7 @@ struct TestAppConfiguration { func applyEndpoints(to config: inout LDConfig) { guard isStaging else { return } - config.baseUrl = URL(string: "https://clientsdk.\(Self.stagingDomain)")! + config.baseUrl = URL(string: "https://app.\(Self.stagingDomain)")! config.streamUrl = URL(string: "https://clientstream.\(Self.stagingDomain)")! config.eventsUrl = URL(string: "https://mobile.\(Self.stagingDomain)")! } From d5da09846568383a0a6d0e67f580ceed8a3147fd Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 23 Oct 2026 07:53:07 -0700 Subject: [PATCH 4/9] Observe each evaluated flag once per client in the test app Co-authored-by: Cursor --- TestApp/Sources/ClientController.swift | 13 +++++++++++++ TestApp/Sources/ContentView.swift | 12 +++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/TestApp/Sources/ClientController.swift b/TestApp/Sources/ClientController.swift index 5a7beb345..8bb695013 100644 --- a/TestApp/Sources/ClientController.swift +++ b/TestApp/Sources/ClientController.swift @@ -33,6 +33,9 @@ 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 = [] + /// What the running client was started with, for the scenarios that depend on its limits. private(set) var config: LDConfig? @@ -98,6 +101,7 @@ final class ClientController: ObservableObject { 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. @@ -128,6 +132,15 @@ 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 + else { return } + client.observe(key: key, owner: self) { change in + Task { @MainActor in changed(change.key) } + } + } + func refreshConnectionStatus() { guard let info = client?.getConnectionInformation() else { diff --git a/TestApp/Sources/ContentView.swift b/TestApp/Sources/ContentView.swift index ffb5e2789..04c08c67d 100644 --- a/TestApp/Sources/ContentView.swift +++ b/TestApp/Sources/ContentView.swift @@ -131,13 +131,11 @@ private struct FlagSection: View { let controller = self.controller controller.countEvaluation() controller.result = "\(key) = \(describe(client, key))" - client.observe(key: key, owner: controller) { [weak controller] changed in - Task { @MainActor in - guard let controller, let client = controller.client - else { return } - controller.countEvaluation() - controller.result = "\(changed.key) changed to \(describe(client, changed.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))" } } From 953b268d5d686f5aee970cb26cd18648718a9ec8 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 27 Oct 2026 09:41:59 -0700 Subject: [PATCH 5/9] Add a Benchmark button running the event persistence benchmark in the test app The button sits under the status text and runs the Android test app's benchmark, section for section, on stores of its own in a temporary directory, then shows the tables in a sheet with Copy. It is in Debug builds only, since it reaches the SDK's internals through @testable import. Co-authored-by: Cursor --- TestApp/README.md | 13 + TestApp/Sources/ContentView.swift | 110 +++++ .../EventPersistenceBenchmarkRunner.swift | 435 ++++++++++++++++++ 3 files changed, 558 insertions(+) create mode 100644 TestApp/Sources/EventPersistenceBenchmarkRunner.swift diff --git a/TestApp/README.md b/TestApp/README.md index afa263057..0ad3da7e1 100644 --- a/TestApp/README.md +++ b/TestApp/README.md @@ -80,3 +80,16 @@ stands in for a UI that re-evaluates a flag far more often than anything changed **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/ContentView.swift b/TestApp/Sources/ContentView.swift index 04c08c67d..dca95fb86 100644 --- a/TestApp/Sources/ContentView.swift +++ b/TestApp/Sources/ContentView.swift @@ -42,6 +42,9 @@ private struct ClientSection: View { .lineLimit(4) .accessibilityIdentifier("result") } + #if DEBUG + BenchmarkButton() + #endif Picker("Persistence", selection: Binding(get: { controller.persistence }, set: { controller.setPersistence($0) })) { ForEach(EventPersistence.choices, id: \.rawValue) { Text($0.name).tag($0) } @@ -300,6 +303,113 @@ private struct DedupeSection: View { } } +#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) + } + } +} + +/// 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. 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 From 17aae6be0d200be4a27b0d28400574bfca1b51c3 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 27 Oct 2026 11:50:03 -0700 Subject: [PATCH 6/9] Add a Post 20 MB button posting one oversized payload past the SDK Posts a 20 MB payload of valid custom events straight to the events service on a session of its own, with the headers the SDK's sender uses, and shows the status, headers and body that come back. Neither the event store, the batch size limit nor the retry state is involved, so the answer to a body that size is seen directly. Next to Benchmark, and like it built only for Debug. Co-authored-by: Cursor --- TestApp/Sources/ContentView.swift | 34 ++++++++- TestApp/Sources/OversizedPostProbe.swift | 89 ++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 TestApp/Sources/OversizedPostProbe.swift diff --git a/TestApp/Sources/ContentView.swift b/TestApp/Sources/ContentView.swift index dca95fb86..44f629745 100644 --- a/TestApp/Sources/ContentView.swift +++ b/TestApp/Sources/ContentView.swift @@ -43,7 +43,10 @@ private struct ClientSection: View { .accessibilityIdentifier("result") } #if DEBUG - BenchmarkButton() + ButtonRow { + BenchmarkButton() + OversizedPostButton() + } #endif Picker("Persistence", selection: Binding(get: { controller.persistence }, set: { controller.setPersistence($0) })) { @@ -365,6 +368,35 @@ private struct BenchmarkButton: View { } } +/// 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 { + ActionButton("Post 20 MB", 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 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 + } +} From fed655e2dfdcabfc206a3585c0a3cb5668582687 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 28 Oct 2026 13:28:14 -0700 Subject: [PATCH 7/9] Show the expected HTTP 413 on the Post 20 MB button Co-authored-by: Cursor --- TestApp/Sources/ContentView.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/TestApp/Sources/ContentView.swift b/TestApp/Sources/ContentView.swift index 44f629745..a0980cdee 100644 --- a/TestApp/Sources/ContentView.swift +++ b/TestApp/Sources/ContentView.swift @@ -377,7 +377,8 @@ private struct OversizedPostButton: View { @State private var results: BenchmarkButton.Results? var body: some View { - ActionButton("Post 20 MB", tint: .purple, id: "oversized_post_button") { run() } + // 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) } } @@ -460,19 +461,29 @@ private struct ButtonRow: View { /// fire together. private struct ActionButton: View { let title: String + let detail: String? let tint: Color let id: String let action: () -> Void - init(_ title: String, tint: Color = .accentColor, id: String, action: @escaping () -> 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(title, action: action) + Button(action: action) { + VStack(spacing: 1) { + Text(title) + if let detail { + Text(detail).font(.caption2) + } + } + } .buttonStyle(.borderedProminent) .tint(tint) .smallControl() From e03f090cf5f147d05ae1b1aa874170afbef7d3f8 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 4 Nov 2026 15:49:32 -0800 Subject: [PATCH 8/9] fix(networking): Share one URLSession across environments Each environment's DarklyService built its own URLSession, and each session keeps its own connection pool, so seven environments posting events and diagnostics to the same host opened at least seven TCP+TLS connections. The session configuration does not depend on the environment, so every service now uses one shared session and their requests reuse connections. Streams are unaffected: they use LDSwiftEventSource's own sessions. Co-authored-by: Cursor --- .../Networking/DarklyService.swift | 33 +++++++++++-------- .../Networking/DarklyServiceSpec.swift | 11 +++++++ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift index 7d980c859..c6d9f166a 100644 --- a/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift +++ b/LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift @@ -52,7 +52,25 @@ final class DarklyService: DarklyServiceProvider { let httpHeaders: HTTPHeaders let diagnosticCache: DiagnosticCaching? private(set) var serviceFactory: ClientServiceCreating - private var session: URLSession + /// Shared by every environment, so that their requests to the same host reuse connections rather than each + /// environment opening its own. Nothing in its configuration depends on the environment; headers and timeouts + /// are set on each request. + static let session: URLSession = { + // URLSessionConfiguration is a class, but `.default` creates a new instance. This does not effect other session configuration. + let sessionConfig = URLSessionConfiguration.default + + if #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) { + sessionConfig.tlsMinimumSupportedProtocolVersion = .TLSv12 + } else { + sessionConfig.tlsMinimumSupportedProtocol = .tlsProtocol12 + } + + // We always revalidate the cache which we handle manually + sessionConfig.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfig.urlCache = nil + return URLSession(configuration: sessionConfig) + }() + let session: URLSession = DarklyService.session var flagRequestEtag: String? init(config: LDConfig, context: LDContext, envReporter: EnvironmentReporting, serviceFactory: ClientServiceCreating) { @@ -67,19 +85,6 @@ final class DarklyService: DarklyServiceProvider { } self.httpHeaders = HTTPHeaders(config: config, environmentReporter: envReporter) - // URLSessionConfiguration is a class, but `.default` creates a new instance. This does not effect other session configuration. - let sessionConfig = URLSessionConfiguration.default - - if #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) { - sessionConfig.tlsMinimumSupportedProtocolVersion = .TLSv12 - } else { - sessionConfig.tlsMinimumSupportedProtocol = .tlsProtocol12 - } - - // We always revalidate the cache which we handle manually - sessionConfig.requestCachePolicy = .reloadIgnoringLocalCacheData - sessionConfig.urlCache = nil - self.session = URLSession(configuration: sessionConfig) } // MARK: Feature Flags diff --git a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift index 19a917e48..4868b54e4 100644 --- a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift @@ -55,6 +55,7 @@ final class DarklyServiceSpec: QuickSpec { createEventSourceSpec() publishEventDataSpec() diagnosticCacheSpec() + sessionSpec() publishDiagnosticSpec() afterEach { @@ -659,6 +660,16 @@ final class DarklyServiceSpec: QuickSpec { } } + private func sessionSpec() { + describe("session") { + it("is shared by every environment") { + let primary = TestContext(mobileKey: LDConfig.Constants.mockMobileKey) + let secondary = TestContext(mobileKey: "secondary-mobile-key") + expect(primary.service.session) === secondary.service.session + } + } + } + private func stubDiagnostic() -> DiagnosticStats { DiagnosticStats(id: DiagnosticId(diagnosticId: "test-id", sdkKey: LDConfig.Constants.mockMobileKey), creationDate: 1000, dataSinceDate: 100, droppedEvents: 0, eventsInLastBatch: 0, streamInits: []) } From a04dd020836bb03cccd6b3f17e45823ebcf33f35 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 6 Nov 2026 00:24:35 -0800 Subject: [PATCH 9/9] TestApp: Add an All flags button showing how many flags the client holds Co-authored-by: Cursor --- TestApp/Sources/ContentView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/TestApp/Sources/ContentView.swift b/TestApp/Sources/ContentView.swift index a0980cdee..3e11a7c4e 100644 --- a/TestApp/Sources/ContentView.swift +++ b/TestApp/Sources/ContentView.swift @@ -127,9 +127,18 @@ private struct FlagSection: View { .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