From c9c135f0013cfaee85e0d1d90007b95893bc48d7 Mon Sep 17 00:00:00 2001 From: yewreeka Date: Mon, 13 Jul 2026 15:52:19 -0700 Subject: [PATCH 1/2] Add metrics tracking to the device pairing flow The pairing handshake emitted no product analytics on either side (the joiner sheet wasn't even screen-tracked). Wire the new device_pairing_* CoreActions events (convos-shared#7) into both pairing view models: one started event plus exactly one terminal completed/failed event per attempt, with failure step, reason (error/expired/cancelled) and duration as properties. A shared DevicePairingMetricsTracker drives this from each view model's flowState didSet, so every path into a terminal state (stream errors, coordinator failures, countdown expiry, redelivered identity shares, cancel-after-terminal dismissals) is counted once without per-site tracking calls. coreActions is threaded from AppSettingsView into DevicesViewModel (settings entry) and from ConversationsViewModel into the joiner sheet and the respond-to-join-request initiator sheet. ConvosCore temporarily pins convos-shared to the feature branch; flip back to main once convos-shared#7 merges. Co-Authored-By: Claude Fable 5 --- .../xcshareddata/swiftpm/Package.resolved | 378 +++++++++--------- Convos/App Settings/AppSettingsView.swift | 3 +- .../ConversationsViewModel.swift | 4 +- .../Devices/DevicePairingMetricsTracker.swift | 78 ++++ Convos/Devices/DevicesViewModel.swift | 9 +- .../Devices/JoinerPairingSheetViewModel.swift | 29 +- Convos/Devices/PairingSheetViewModel.swift | 28 +- ConvosCore/Package.swift | 2 +- .../ConvosCore/Metrics/NoOpCoreActions.swift | 11 + 9 files changed, 345 insertions(+), 197 deletions(-) create mode 100644 Convos/Devices/DevicePairingMetricsTracker.swift diff --git a/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5f1272d8d..8e31a5149 100644 --- a/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,285 +1,285 @@ { - "originHash" : "ab9adf98b174b3dddda1af96a5ab505035bb5d4b2bf723d39d30345f7fc55443", - "pins" : [ + "originHash": "fbf7fae6ac9435c5f61b93f747ee22781cc9a18d733e7e1f2a25de27b4d3ad6b", + "pins": [ { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" + "identity": "abseil-cpp-binary", + "kind": "remoteSourceControl", + "location": "https://github.com/google/abseil-cpp-binary.git", + "state": { + "revision": "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version": "1.2024072200.0" } }, { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" + "identity": "app-check", + "kind": "remoteSourceControl", + "location": "https://github.com/google/app-check.git", + "state": { + "revision": "61b85103a1aeed8218f17c794687781505fbbef5", + "version": "11.2.0" } }, { - "identity" : "connect-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/bufbuild/connect-swift", - "state" : { - "revision" : "8ff57dad962668357af2959fdd63868682f3053e", - "version" : "1.2.0" + "identity": "connect-swift", + "kind": "remoteSourceControl", + "location": "https://github.com/bufbuild/connect-swift", + "state": { + "revision": "8ff57dad962668357af2959fdd63868682f3053e", + "version": "1.2.0" } }, { - "identity" : "convos-shared", - "kind" : "remoteSourceControl", - "location" : "https://github.com/xmtplabs/convos-shared.git", - "state" : { - "branch" : "main", - "revision" : "8b5f741bb0368c1a4edea87f00c1ec0463b181bb" + "identity": "convos-shared", + "kind": "remoteSourceControl", + "location": "https://github.com/xmtplabs/convos-shared.git", + "state": { + "branch": "jarod/device-pairing-metrics", + "revision": "ef75ce108f525bcf4fbad32a5ac45035b6815c60" } }, { - "identity" : "cryptoswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", - "state" : { - "revision" : "e45a26384239e028ec87fbcc788f513b67e10d8f", - "version" : "1.9.0" + "identity": "cryptoswift", + "kind": "remoteSourceControl", + "location": "https://github.com/krzyzanowskim/CryptoSwift.git", + "state": { + "revision": "e45a26384239e028ec87fbcc788f513b67e10d8f", + "version": "1.9.0" } }, { - "identity" : "csecp256k1.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tesseract-one/CSecp256k1.swift.git", - "state" : { - "revision" : "cfbd6f540d5084bc96a60af841121472fbe725a3", - "version" : "0.2.0" + "identity": "csecp256k1.swift", + "kind": "remoteSourceControl", + "location": "https://github.com/tesseract-one/CSecp256k1.swift.git", + "state": { + "revision": "cfbd6f540d5084bc96a60af841121472fbe725a3", + "version": "0.2.0" } }, { - "identity" : "differencekit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ra1028/DifferenceKit.git", - "state" : { - "revision" : "073b9671ce2b9b5b96398611427a1f929927e428", - "version" : "1.3.0" + "identity": "differencekit", + "kind": "remoteSourceControl", + "location": "https://github.com/ra1028/DifferenceKit.git", + "state": { + "revision": "073b9671ce2b9b5b96398611427a1f929927e428", + "version": "1.3.0" } }, { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "b9bf3adac18e6e3059167194aeb632f15a5ba4b2", - "version" : "12.1.0" + "identity": "firebase-ios-sdk", + "kind": "remoteSourceControl", + "location": "https://github.com/firebase/firebase-ios-sdk", + "state": { + "revision": "b9bf3adac18e6e3059167194aeb632f15a5ba4b2", + "version": "12.1.0" } }, { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "e15f979c3eaf477d24e5bfec9d87f1d76fbac297", - "version" : "2.2.0" + "identity": "google-ads-on-device-conversion-ios-sdk", + "kind": "remoteSourceControl", + "location": "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state": { + "revision": "e15f979c3eaf477d24e5bfec9d87f1d76fbac297", + "version": "2.2.0" } }, { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "883305109ead4599e4ca59591754ee72e81e6b5e", - "version" : "12.1.0" + "identity": "googleappmeasurement", + "kind": "remoteSourceControl", + "location": "https://github.com/google/GoogleAppMeasurement.git", + "state": { + "revision": "883305109ead4599e4ca59591754ee72e81e6b5e", + "version": "12.1.0" } }, { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" + "identity": "googledatatransport", + "kind": "remoteSourceControl", + "location": "https://github.com/google/GoogleDataTransport.git", + "state": { + "revision": "617af071af9aa1d6a091d59a202910ac482128f9", + "version": "10.1.0" } }, { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", - "version" : "8.1.0" + "identity": "googleutilities", + "kind": "remoteSourceControl", + "location": "https://github.com/google/GoogleUtilities.git", + "state": { + "revision": "60da361632d0de02786f709bdc0c4df340f7613e", + "version": "8.1.0" } }, { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift.git", - "state" : { - "revision" : "a5a1be26b4513dc7ec360eb56bc08a345bac6649", - "version" : "7.5.0" + "identity": "grdb.swift", + "kind": "remoteSourceControl", + "location": "https://github.com/groue/GRDB.swift.git", + "state": { + "revision": "a5a1be26b4513dc7ec360eb56bc08a345bac6649", + "version": "7.5.0" } }, { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", - "version" : "1.69.0" + "identity": "grpc-binary", + "kind": "remoteSourceControl", + "location": "https://github.com/google/grpc-binary.git", + "state": { + "revision": "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", + "version": "1.69.0" } }, { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", - "version" : "5.0.0" + "identity": "gtm-session-fetcher", + "kind": "remoteSourceControl", + "location": "https://github.com/google/gtm-session-fetcher.git", + "state": { + "revision": "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", + "version": "5.0.0" } }, { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" + "identity": "interop-ios-for-google-sdks", + "kind": "remoteSourceControl", + "location": "https://github.com/google/interop-ios-for-google-sdks.git", + "state": { + "revision": "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version": "101.0.0" } }, { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" + "identity": "leveldb", + "kind": "remoteSourceControl", + "location": "https://github.com/firebase/leveldb.git", + "state": { + "revision": "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version": "1.22.5" } }, { - "identity" : "libxmtp", - "kind" : "remoteSourceControl", - "location" : "https://github.com/xmtp/libxmtp.git", - "state" : { - "branch" : "ios-4.11.0-nightly.20260711.9358d22", - "revision" : "88b7cb0207210f1f7c0f46360d75c186143f41ce" + "identity": "libxmtp", + "kind": "remoteSourceControl", + "location": "https://github.com/xmtp/libxmtp.git", + "state": { + "branch": "ios-4.11.0-nightly.20260711.9358d22", + "revision": "88b7cb0207210f1f7c0f46360d75c186143f41ce" } }, { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version" : "2.30910.0" + "identity": "nanopb", + "kind": "remoteSourceControl", + "location": "https://github.com/firebase/nanopb.git", + "state": { + "revision": "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version": "2.30910.0" } }, { - "identity" : "posthog-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/PostHog/posthog-ios.git", - "state" : { - "revision" : "218fb6fe016a27baa3651613d53dae5c6e36af15", - "version" : "3.57.3" + "identity": "posthog-ios", + "kind": "remoteSourceControl", + "location": "https://github.com/PostHog/posthog-ios.git", + "state": { + "revision": "218fb6fe016a27baa3651613d53dae5c6e36af15", + "version": "3.57.3" } }, { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" + "identity": "promises", + "kind": "remoteSourceControl", + "location": "https://github.com/google/promises.git", + "state": { + "revision": "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version": "2.4.0" } }, { - "identity" : "sentry-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/getsentry/sentry-cocoa.git", - "state" : { - "revision" : "e1e94efb9d5a688af85c607ae34a8b56961ca118", - "version" : "8.57.1" + "identity": "sentry-cocoa", + "kind": "remoteSourceControl", + "location": "https://github.com/getsentry/sentry-cocoa.git", + "state": { + "revision": "e1e94efb9d5a688af85c607ae34a8b56961ca118", + "version": "8.57.1" } }, { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", - "version" : "1.3.1" + "identity": "swift-atomics", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-atomics.git", + "state": { + "revision": "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version": "1.3.1" } }, { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", - "version" : "1.6.0" + "identity": "swift-collections", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-collections.git", + "state": { + "revision": "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version": "1.6.0" } }, { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log", - "state" : { - "revision" : "2aed77ae5ec9a86d8fe42c12275e4c2653a286ee", - "version" : "1.13.1" + "identity": "swift-log", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-log", + "state": { + "revision": "2aed77ae5ec9a86d8fe42c12275e4c2653a286ee", + "version": "1.13.1" } }, { - "identity" : "swift-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", - "state" : { - "revision" : "cd3e1152083706d77b223fb29110e590efcc70c0", - "version" : "2.101.2" + "identity": "swift-nio", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-nio.git", + "state": { + "revision": "cd3e1152083706d77b223fb29110e590efcc70c0", + "version": "2.101.2" } }, { - "identity" : "swift-nio-http2", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-http2.git", - "state" : { - "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", - "version" : "1.44.0" + "identity": "swift-nio-http2", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-nio-http2.git", + "state": { + "revision": "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version": "1.44.0" } }, { - "identity" : "swift-nio-ssl", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssl.git", - "state" : { - "revision" : "407d82d5b6cc00e1c3fb83a81b1539b70c788c5e", - "version" : "2.37.1" + "identity": "swift-nio-ssl", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-nio-ssl.git", + "state": { + "revision": "407d82d5b6cc00e1c3fb83a81b1539b70c788c5e", + "version": "2.37.1" } }, { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "2547102afd04fe49f1b286090f13ebce07284980", - "version" : "1.31.1" + "identity": "swift-protobuf", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-protobuf.git", + "state": { + "revision": "2547102afd04fe49f1b286090f13ebce07284980", + "version": "1.31.1" } }, { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", - "state" : { - "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1", - "version" : "1.7.2" + "identity": "swift-system", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-system.git", + "state": { + "revision": "7502b711c92a17741fa625d722b0ccbd595d8ed1", + "version": "1.7.2" } }, { - "identity" : "swiftui-introspect", - "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/swiftui-introspect.git", - "state" : { - "revision" : "9bedf1e79c7b7c34b35b74bef126f56d2400ef7f", - "version" : "1.4.0-beta.4" + "identity": "swiftui-introspect", + "kind": "remoteSourceControl", + "location": "https://github.com/siteline/swiftui-introspect.git", + "state": { + "revision": "9bedf1e79c7b7c34b35b74bef126f56d2400ef7f", + "version": "1.4.0-beta.4" } } ], - "version" : 3 + "version": 3 } diff --git a/Convos/App Settings/AppSettingsView.swift b/Convos/App Settings/AppSettingsView.swift index 22ed3d186..d62ef3272 100644 --- a/Convos/App Settings/AppSettingsView.swift +++ b/Convos/App Settings/AppSettingsView.swift @@ -177,7 +177,8 @@ struct AppSettingsView: View { DeferredInitiatorPairingService(session: session) }, session: session, - appGroupIdentifier: ConfigManager.shared.currentEnvironment.appGroupIdentifier + appGroupIdentifier: ConfigManager.shared.currentEnvironment.appGroupIdentifier, + coreActions: coreActions ) ) .onAppear { navigator?.navigateTo(devices: DevicesNavigatorArgs()) } diff --git a/Convos/Conversations List/ConversationsViewModel.swift b/Convos/Conversations List/ConversationsViewModel.swift index 258bb2cca..556eabc5d 100644 --- a/Convos/Conversations List/ConversationsViewModel.swift +++ b/Convos/Conversations List/ConversationsViewModel.swift @@ -823,6 +823,7 @@ extension ConversationsViewModel { connectingMessage: connectingMessage, resendJoinRequestInterval: resendJoinRequestInterval, pairingService: session.joinerPairingService(), + coreActions: coreActions, onPairingAdopted: { [weak self] in await self?.session.refreshAfterPairingCompleted() }, @@ -1172,7 +1173,8 @@ extension ConversationsViewModel { incomingPairingRequest = PairingSheetViewModel( pairingService: DeferredInitiatorPairingService(session: session), mode: .respondToJoinRequest(joinerInboxId: joinerInboxId, deviceName: deviceName), - appGroupIdentifier: ConfigManager.shared.currentEnvironment.appGroupIdentifier + appGroupIdentifier: ConfigManager.shared.currentEnvironment.appGroupIdentifier, + coreActions: coreActions ) } diff --git a/Convos/Devices/DevicePairingMetricsTracker.swift b/Convos/Devices/DevicePairingMetricsTracker.swift new file mode 100644 index 000000000..760f7bcc5 --- /dev/null +++ b/Convos/Devices/DevicePairingMetricsTracker.swift @@ -0,0 +1,78 @@ +import ConvosMetrics +import Foundation + +/// Funnels one device-pairing attempt into the device-pairing metrics +/// events: at most one `devicePairingStarted` plus exactly one terminal +/// `devicePairingCompleted` / `devicePairingFailed`, no matter how many +/// code paths can reach each flow state (stream redelivery, resend loops, +/// cancel-after-terminal dismissals). +/// +/// Both pairing view models drive it from their `flowState` `didSet`, so +/// step bookkeeping stays correct even for transitions added later; the +/// explicit calls are only `started()` (flow kickoff) and `cancelled()` +/// (user dismissal, which no-ops once a terminal event has fired). +@MainActor +final class DevicePairingMetricsTracker { + private let role: DevicePairingRole + private let coreActions: any CoreActions + private var startedAt: Date? + private var lastStep: DevicePairingStep + private var didFinish: Bool = false + + init(role: DevicePairingRole, coreActions: any CoreActions) { + self.role = role + self.coreActions = coreActions + // The step a flow is in before its first tracked transition; only + // reported if the attempt dies that early. + self.lastStep = role == .initiator ? .qrDisplayed : .joinRequested + } + + func started() { + guard startedAt == nil else { return } + startedAt = Date() + let actions = coreActions + let role = role + Task { await actions.devicePairingStarted(role: role) } + } + + func reached(_ step: DevicePairingStep) { + guard !didFinish else { return } + lastStep = step + } + + func completed() { + guard finishOnce() else { return } + let actions = coreActions + let role = role + let duration = durationSecs + Task { await actions.devicePairingCompleted(role: role, durationSecs: duration) } + } + + func failed(_ reason: DevicePairingFailureReason) { + guard finishOnce() else { return } + let actions = coreActions + let role = role + let step = lastStep + let duration = durationSecs + Task { await actions.devicePairingFailed(role: role, reason: reason, step: step, durationSecs: duration) } + } + + /// User dismissal. Safe to call unconditionally on teardown: it only + /// emits when the attempt started and hasn't already ended. + func cancelled() { + failed(.cancelled) + } + + /// False when the attempt never started or already emitted its + /// terminal event. + private func finishOnce() -> Bool { + guard !didFinish, startedAt != nil else { return false } + didFinish = true + return true + } + + private var durationSecs: Float { + guard let startedAt else { return 0 } + return Float(Date().timeIntervalSince(startedAt)) + } +} diff --git a/Convos/Devices/DevicesViewModel.swift b/Convos/Devices/DevicesViewModel.swift index bb417f622..2808aa788 100644 --- a/Convos/Devices/DevicesViewModel.swift +++ b/Convos/Devices/DevicesViewModel.swift @@ -1,4 +1,5 @@ import ConvosCore +import ConvosMetrics import Observation import SwiftUI @@ -41,6 +42,7 @@ final class DevicesViewModel { private let pairingServiceFactory: @MainActor () -> any PairingServiceProtocol private let session: (any SessionManagerProtocol)? private let appGroupIdentifier: String? + private let coreActions: any CoreActions @ObservationIgnored private let observers: PairingNotificationObservers = .init() @ObservationIgnored @@ -58,11 +60,13 @@ final class DevicesViewModel { init( pairingServiceFactory: @escaping @MainActor () -> any PairingServiceProtocol, session: (any SessionManagerProtocol)? = nil, - appGroupIdentifier: String? = nil + appGroupIdentifier: String? = nil, + coreActions: any CoreActions = NoOpCoreActions() ) { self.pairingServiceFactory = pairingServiceFactory self.session = session self.appGroupIdentifier = appGroupIdentifier + self.coreActions = coreActions self.devices = [ PairedDevice( id: "self", @@ -283,7 +287,8 @@ final class DevicesViewModel { let service = pairingServiceFactory() let vm = PairingSheetViewModel( pairingService: service, - appGroupIdentifier: appGroupIdentifier + appGroupIdentifier: appGroupIdentifier, + coreActions: coreActions ) pairingViewModel = vm showPairingSheet = true diff --git a/Convos/Devices/JoinerPairingSheetViewModel.swift b/Convos/Devices/JoinerPairingSheetViewModel.swift index 1a9681032..0d2d12784 100644 --- a/Convos/Devices/JoinerPairingSheetViewModel.swift +++ b/Convos/Devices/JoinerPairingSheetViewModel.swift @@ -1,4 +1,5 @@ import ConvosCore +import ConvosMetrics import CryptoKit import Observation import SwiftUI @@ -19,7 +20,9 @@ enum JoinerPairingFlowState: Equatable { @MainActor final class JoinerPairingSheetViewModel: Identifiable { nonisolated let id: String - var flowState: JoinerPairingFlowState + var flowState: JoinerPairingFlowState { + didSet { trackFlowState() } + } var title: String = "Request to pair" var canDismiss: Bool = true var secondsRemaining: Int @@ -60,6 +63,9 @@ final class JoinerPairingSheetViewModel: Identifiable { /// most once. private var didComplete: Bool = false + @ObservationIgnored + private let metrics: DevicePairingMetricsTracker + init( pairingId: String, expiresAt: Date? = nil, @@ -68,6 +74,7 @@ final class JoinerPairingSheetViewModel: Identifiable { connectingMessage: String? = nil, resendJoinRequestInterval: TimeInterval? = nil, pairingService: any PairingServiceProtocol, + coreActions: any CoreActions = NoOpCoreActions(), onPairingAdopted: (@MainActor () async -> Void)? = nil, onApplyAdoptedProfile: (@MainActor (_ displayName: String?, _ imageAssetIdentifier: String?) async -> Void)? = nil, onDeleteExistingData: (@MainActor () async throws -> Void)? = nil, @@ -80,6 +87,7 @@ final class JoinerPairingSheetViewModel: Identifiable { self.connectingMessage = connectingMessage self.resendJoinRequestInterval = resendJoinRequestInterval self.pairingService = pairingService + self.metrics = DevicePairingMetricsTracker(role: .joiner, coreActions: coreActions) self.onPairingAdopted = onPairingAdopted self.onApplyAdoptedProfile = onApplyAdoptedProfile self.onDeleteExistingData = onDeleteExistingData @@ -142,7 +150,25 @@ final class JoinerPairingSheetViewModel: Identifiable { enteredPin.count == 6 } + /// Routes every `flowState` transition into the metrics tracker so all + /// paths to a terminal state (stream errors, send failures, countdown + /// expiry, identity-share completion) are counted without per-site + /// tracking calls. + private func trackFlowState() { + switch flowState { + case .connecting: metrics.reached(.joinRequested) + case .needsDataDeletion, .deletingData: metrics.reached(.dataDeletion) + case .pinEntry: metrics.reached(.pinEntry) + case .waitingForEmoji: metrics.reached(.emojiConfirmation) + case .syncing: metrics.reached(.syncing) + case .completed: metrics.completed() + case .failed: metrics.failed(.error) + case .expired: metrics.failed(.expired) + } + } + func sendJoinRequest() async { + metrics.started() // Check first whether the device has any real conversation data — // i.e. anything the user would actually lose. A placeholder // identity + pre-warmed unused convo cache from silent identity @@ -294,6 +320,7 @@ final class JoinerPairingSheetViewModel: Identifiable { } func cancel() { + metrics.cancelled() countdownTask?.cancel() resendTask?.cancel() Task { diff --git a/Convos/Devices/PairingSheetViewModel.swift b/Convos/Devices/PairingSheetViewModel.swift index 581dcc5d9..b5ec58cec 100644 --- a/Convos/Devices/PairingSheetViewModel.swift +++ b/Convos/Devices/PairingSheetViewModel.swift @@ -1,4 +1,5 @@ import ConvosCore +import ConvosMetrics import Observation import SwiftUI @@ -57,7 +58,9 @@ final class PairingSheetViewModel: Identifiable { return false } - var flowState: PairingFlowState = .qrCode(url: "") + var flowState: PairingFlowState = .qrCode(url: "") { + didSet { trackFlowState() } + } var canDismiss: Bool = true var title: String = "Pair new device" var secondsRemaining: Int = 60 @@ -73,17 +76,21 @@ final class PairingSheetViewModel: Identifiable { private var countdownTask: Task? @ObservationIgnored private let observers: PairingNotificationObservers + @ObservationIgnored + private let metrics: DevicePairingMetricsTracker init( pairingService: any PairingServiceProtocol, timeoutInterval: TimeInterval = 120, mode: PairingSheetMode = .createInvite, - appGroupIdentifier: String? = nil + appGroupIdentifier: String? = nil, + coreActions: any CoreActions = NoOpCoreActions() ) { self.pairingService = pairingService self.appGroupIdentifier = appGroupIdentifier self.timeoutInterval = timeoutInterval self.mode = mode + self.metrics = DevicePairingMetricsTracker(role: .initiator, coreActions: coreActions) self.secondsRemaining = Int(timeoutInterval) self.observers = PairingNotificationObservers() if case .respondToJoinRequest = mode { @@ -127,8 +134,24 @@ final class PairingSheetViewModel: Identifiable { } } + /// Routes every `flowState` transition into the metrics tracker so all + /// paths to a terminal state (stream errors, coordinator failures, + /// countdown expiry) are counted without per-site tracking calls. + private func trackFlowState() { + switch flowState { + case .qrCode: metrics.reached(.qrDisplayed) + case .showingPin: metrics.reached(.pinShown) + case .emojiConfirmation: metrics.reached(.emojiConfirmation) + case .syncing: metrics.reached(.syncing) + case .completed: metrics.completed() + case .failed: metrics.failed(.error) + case .expired: metrics.failed(.expired) + } + } + func startPairing() async { Self.active = self + metrics.started() switch mode { case .createInvite: await startInviteFlow() @@ -336,6 +359,7 @@ final class PairingSheetViewModel: Identifiable { } func cancel() async { + metrics.cancelled() countdownTask?.cancel() if let coordinator, let joinerInboxId { let state = await coordinator.currentState diff --git a/ConvosCore/Package.swift b/ConvosCore/Package.swift index 92559ac23..9aade2f18 100644 --- a/ConvosCore/Package.swift +++ b/ConvosCore/Package.swift @@ -34,7 +34,7 @@ let package = Package( .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "12.1.0"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.31.1"), .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "8.57.1"), - .package(url: "https://github.com/xmtplabs/convos-shared.git", branch: "main"), + .package(url: "https://github.com/xmtplabs/convos-shared.git", branch: "jarod/device-pairing-metrics"), .package(path: "../ConvosLogging"), .package(path: "../ConvosInvites"), .package(path: "../ConvosAppData"), diff --git a/ConvosCore/Sources/ConvosCore/Metrics/NoOpCoreActions.swift b/ConvosCore/Sources/ConvosCore/Metrics/NoOpCoreActions.swift index 329391b6e..94b5bbb9c 100644 --- a/ConvosCore/Sources/ConvosCore/Metrics/NoOpCoreActions.swift +++ b/ConvosCore/Sources/ConvosCore/Metrics/NoOpCoreActions.swift @@ -89,4 +89,15 @@ public final class NoOpCoreActions: CoreActions, @unchecked Sendable { ) async {} public func purchasesRestored(restoredCount: Int) async {} + + public func devicePairingStarted(role: DevicePairingRole) async {} + + public func devicePairingCompleted(role: DevicePairingRole, durationSecs: Float) async {} + + public func devicePairingFailed( + role: DevicePairingRole, + reason: DevicePairingFailureReason, + step: DevicePairingStep, + durationSecs: Float + ) async {} } From 706cd58eba6a8f9ca927e270cebc39320b6dfed1 Mon Sep 17 00:00:00 2001 From: yewreeka Date: Mon, 13 Jul 2026 15:53:02 -0700 Subject: [PATCH 2/2] Keep Package.resolved in Xcode's own formatting; only the convos-shared pin changes Co-Authored-By: Claude Fable 5 --- .../xcshareddata/swiftpm/Package.resolved | 378 +++++++++--------- 1 file changed, 189 insertions(+), 189 deletions(-) diff --git a/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8e31a5149..ef537ea04 100644 --- a/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Convos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,285 +1,285 @@ { - "originHash": "fbf7fae6ac9435c5f61b93f747ee22781cc9a18d733e7e1f2a25de27b4d3ad6b", - "pins": [ + "originHash" : "ab9adf98b174b3dddda1af96a5ab505035bb5d4b2bf723d39d30345f7fc55443", + "pins" : [ { - "identity": "abseil-cpp-binary", - "kind": "remoteSourceControl", - "location": "https://github.com/google/abseil-cpp-binary.git", - "state": { - "revision": "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version": "1.2024072200.0" + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" } }, { - "identity": "app-check", - "kind": "remoteSourceControl", - "location": "https://github.com/google/app-check.git", - "state": { - "revision": "61b85103a1aeed8218f17c794687781505fbbef5", - "version": "11.2.0" + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" } }, { - "identity": "connect-swift", - "kind": "remoteSourceControl", - "location": "https://github.com/bufbuild/connect-swift", - "state": { - "revision": "8ff57dad962668357af2959fdd63868682f3053e", - "version": "1.2.0" + "identity" : "connect-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/bufbuild/connect-swift", + "state" : { + "revision" : "8ff57dad962668357af2959fdd63868682f3053e", + "version" : "1.2.0" } }, { - "identity": "convos-shared", - "kind": "remoteSourceControl", - "location": "https://github.com/xmtplabs/convos-shared.git", - "state": { - "branch": "jarod/device-pairing-metrics", - "revision": "ef75ce108f525bcf4fbad32a5ac45035b6815c60" + "identity" : "convos-shared", + "kind" : "remoteSourceControl", + "location" : "https://github.com/xmtplabs/convos-shared.git", + "state" : { + "branch" : "jarod/device-pairing-metrics", + "revision" : "ef75ce108f525bcf4fbad32a5ac45035b6815c60" } }, { - "identity": "cryptoswift", - "kind": "remoteSourceControl", - "location": "https://github.com/krzyzanowskim/CryptoSwift.git", - "state": { - "revision": "e45a26384239e028ec87fbcc788f513b67e10d8f", - "version": "1.9.0" + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", + "state" : { + "revision" : "e45a26384239e028ec87fbcc788f513b67e10d8f", + "version" : "1.9.0" } }, { - "identity": "csecp256k1.swift", - "kind": "remoteSourceControl", - "location": "https://github.com/tesseract-one/CSecp256k1.swift.git", - "state": { - "revision": "cfbd6f540d5084bc96a60af841121472fbe725a3", - "version": "0.2.0" + "identity" : "csecp256k1.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tesseract-one/CSecp256k1.swift.git", + "state" : { + "revision" : "cfbd6f540d5084bc96a60af841121472fbe725a3", + "version" : "0.2.0" } }, { - "identity": "differencekit", - "kind": "remoteSourceControl", - "location": "https://github.com/ra1028/DifferenceKit.git", - "state": { - "revision": "073b9671ce2b9b5b96398611427a1f929927e428", - "version": "1.3.0" + "identity" : "differencekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ra1028/DifferenceKit.git", + "state" : { + "revision" : "073b9671ce2b9b5b96398611427a1f929927e428", + "version" : "1.3.0" } }, { - "identity": "firebase-ios-sdk", - "kind": "remoteSourceControl", - "location": "https://github.com/firebase/firebase-ios-sdk", - "state": { - "revision": "b9bf3adac18e6e3059167194aeb632f15a5ba4b2", - "version": "12.1.0" + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "b9bf3adac18e6e3059167194aeb632f15a5ba4b2", + "version" : "12.1.0" } }, { - "identity": "google-ads-on-device-conversion-ios-sdk", - "kind": "remoteSourceControl", - "location": "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state": { - "revision": "e15f979c3eaf477d24e5bfec9d87f1d76fbac297", - "version": "2.2.0" + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "e15f979c3eaf477d24e5bfec9d87f1d76fbac297", + "version" : "2.2.0" } }, { - "identity": "googleappmeasurement", - "kind": "remoteSourceControl", - "location": "https://github.com/google/GoogleAppMeasurement.git", - "state": { - "revision": "883305109ead4599e4ca59591754ee72e81e6b5e", - "version": "12.1.0" + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "883305109ead4599e4ca59591754ee72e81e6b5e", + "version" : "12.1.0" } }, { - "identity": "googledatatransport", - "kind": "remoteSourceControl", - "location": "https://github.com/google/GoogleDataTransport.git", - "state": { - "revision": "617af071af9aa1d6a091d59a202910ac482128f9", - "version": "10.1.0" + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" } }, { - "identity": "googleutilities", - "kind": "remoteSourceControl", - "location": "https://github.com/google/GoogleUtilities.git", - "state": { - "revision": "60da361632d0de02786f709bdc0c4df340f7613e", - "version": "8.1.0" + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" } }, { - "identity": "grdb.swift", - "kind": "remoteSourceControl", - "location": "https://github.com/groue/GRDB.swift.git", - "state": { - "revision": "a5a1be26b4513dc7ec360eb56bc08a345bac6649", - "version": "7.5.0" + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "revision" : "a5a1be26b4513dc7ec360eb56bc08a345bac6649", + "version" : "7.5.0" } }, { - "identity": "grpc-binary", - "kind": "remoteSourceControl", - "location": "https://github.com/google/grpc-binary.git", - "state": { - "revision": "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", - "version": "1.69.0" + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", + "version" : "1.69.0" } }, { - "identity": "gtm-session-fetcher", - "kind": "remoteSourceControl", - "location": "https://github.com/google/gtm-session-fetcher.git", - "state": { - "revision": "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", - "version": "5.0.0" + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", + "version" : "5.0.0" } }, { - "identity": "interop-ios-for-google-sdks", - "kind": "remoteSourceControl", - "location": "https://github.com/google/interop-ios-for-google-sdks.git", - "state": { - "revision": "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version": "101.0.0" + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" } }, { - "identity": "leveldb", - "kind": "remoteSourceControl", - "location": "https://github.com/firebase/leveldb.git", - "state": { - "revision": "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version": "1.22.5" + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" } }, { - "identity": "libxmtp", - "kind": "remoteSourceControl", - "location": "https://github.com/xmtp/libxmtp.git", - "state": { - "branch": "ios-4.11.0-nightly.20260711.9358d22", - "revision": "88b7cb0207210f1f7c0f46360d75c186143f41ce" + "identity" : "libxmtp", + "kind" : "remoteSourceControl", + "location" : "https://github.com/xmtp/libxmtp.git", + "state" : { + "branch" : "ios-4.11.0-nightly.20260711.9358d22", + "revision" : "88b7cb0207210f1f7c0f46360d75c186143f41ce" } }, { - "identity": "nanopb", - "kind": "remoteSourceControl", - "location": "https://github.com/firebase/nanopb.git", - "state": { - "revision": "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version": "2.30910.0" + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" } }, { - "identity": "posthog-ios", - "kind": "remoteSourceControl", - "location": "https://github.com/PostHog/posthog-ios.git", - "state": { - "revision": "218fb6fe016a27baa3651613d53dae5c6e36af15", - "version": "3.57.3" + "identity" : "posthog-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PostHog/posthog-ios.git", + "state" : { + "revision" : "218fb6fe016a27baa3651613d53dae5c6e36af15", + "version" : "3.57.3" } }, { - "identity": "promises", - "kind": "remoteSourceControl", - "location": "https://github.com/google/promises.git", - "state": { - "revision": "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version": "2.4.0" + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" } }, { - "identity": "sentry-cocoa", - "kind": "remoteSourceControl", - "location": "https://github.com/getsentry/sentry-cocoa.git", - "state": { - "revision": "e1e94efb9d5a688af85c607ae34a8b56961ca118", - "version": "8.57.1" + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa.git", + "state" : { + "revision" : "e1e94efb9d5a688af85c607ae34a8b56961ca118", + "version" : "8.57.1" } }, { - "identity": "swift-atomics", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-atomics.git", - "state": { - "revision": "0442cb5a3f98ab802acb777929fdb446bda11a34", - "version": "1.3.1" + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { - "identity": "swift-collections", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-collections.git", - "state": { - "revision": "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", - "version": "1.6.0" + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { - "identity": "swift-log", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-log", - "state": { - "revision": "2aed77ae5ec9a86d8fe42c12275e4c2653a286ee", - "version": "1.13.1" + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "2aed77ae5ec9a86d8fe42c12275e4c2653a286ee", + "version" : "1.13.1" } }, { - "identity": "swift-nio", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-nio.git", - "state": { - "revision": "cd3e1152083706d77b223fb29110e590efcc70c0", - "version": "2.101.2" + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "cd3e1152083706d77b223fb29110e590efcc70c0", + "version" : "2.101.2" } }, { - "identity": "swift-nio-http2", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-nio-http2.git", - "state": { - "revision": "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", - "version": "1.44.0" + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" } }, { - "identity": "swift-nio-ssl", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-nio-ssl.git", - "state": { - "revision": "407d82d5b6cc00e1c3fb83a81b1539b70c788c5e", - "version": "2.37.1" + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "407d82d5b6cc00e1c3fb83a81b1539b70c788c5e", + "version" : "2.37.1" } }, { - "identity": "swift-protobuf", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-protobuf.git", - "state": { - "revision": "2547102afd04fe49f1b286090f13ebce07284980", - "version": "1.31.1" + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "2547102afd04fe49f1b286090f13ebce07284980", + "version" : "1.31.1" } }, { - "identity": "swift-system", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-system.git", - "state": { - "revision": "7502b711c92a17741fa625d722b0ccbd595d8ed1", - "version": "1.7.2" + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1", + "version" : "1.7.2" } }, { - "identity": "swiftui-introspect", - "kind": "remoteSourceControl", - "location": "https://github.com/siteline/swiftui-introspect.git", - "state": { - "revision": "9bedf1e79c7b7c34b35b74bef126f56d2400ef7f", - "version": "1.4.0-beta.4" + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/swiftui-introspect.git", + "state" : { + "revision" : "9bedf1e79c7b7c34b35b74bef126f56d2400ef7f", + "version" : "1.4.0-beta.4" } } ], - "version": 3 + "version" : 3 }