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