From a0c9ba476ca1975459bb47b659c5a0e3ee0847fc Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:54:11 -0700 Subject: [PATCH 1/2] feat(ios): state connection method and live transport in diagnostics A transport report today only implies the connection method and carrier transport through whichever dial events survived the bounded ring; reading one still needs a Settings screenshot. Record the configured method (connectionMethodConfigured) at store construction and on every foreground so any report window states it, decode the existing connectionMethodPreferenceChanged value into the same readable method name, and record foregroundTransportSelected with the active route's transport on connect and on every route change, covering both state-then-route and route-then-state connect flows plus mid-connection promotions. Reports now carry lines like: App feature event (Operation: connectionMethodConfigured, Method: Tailscale Only) App feature event (Operation: foregroundTransportSelected, Transport: Iroh) en+ja catalog entries for the new method and field names. Co-Authored-By: Claude Fable 5 --- .../DiagnosticEventPresentation.swift | 19 ++++- .../CMUXMobileCore/DiagnosticTaxonomy.swift | 17 ++++ .../Resources/Localizable.xcstrings | 51 +++++++++++ .../DiagnosticEventPresentationTests.swift | 42 +++++++++ .../MobileShellComposite.swift | 18 ++++ ...eForegroundTransportDiagnosticsTests.swift | 85 +++++++++++++++++++ .../MobileConnectionMethodStore.swift | 14 +++ .../MobileConnectionMethodStoreTests.swift | 46 ++++++++-- ios/cmux/AppCompositionRoot.swift | 1 + 9 files changed, 287 insertions(+), 6 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileForegroundTransportDiagnosticsTests.swift diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift index a2c04fb8a1c1..34a00164524c 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift @@ -213,6 +213,14 @@ public struct DiagnosticEventPresentation: Sendable { } } + /// Human-readable name of a configured connection method. + public func displayName(_ method: DiagnosticConnectionMethod) -> String { + switch method { + case .automatic: localized("diagnostics.connectionMethod.automatic", defaultValue: "Auto-Connect (Iroh)") + case .tailscale: localized("diagnostics.connectionMethod.tailscale", defaultValue: "Tailscale Only") + } + } + /// Human-readable name of a selected network path. public func displayName(_ kind: DiagnosticPathKind) -> String { switch kind { @@ -681,6 +689,10 @@ public struct DiagnosticEventPresentation: Sendable { return Field(key: "style", value: toastStyleName(raw)) case .toastDismissed: return Field(key: "reason", value: toastDismissReasonName(raw)) + case .connectionMethodPreferenceChanged, .connectionMethodConfigured: + return Field(key: "method", value: connectionMethodName(raw)) + case .foregroundTransportSelected: + return Field(key: "transport", value: transportName(raw)) default: if Self.appEventKindsWithValuePayload.contains(kind) { return Field(key: "value", value: String(raw)) @@ -743,6 +755,11 @@ public struct DiagnosticEventPresentation: Sendable { ?? unknownPayloadName(raw) } + private func connectionMethodName(_ raw: Int) -> String { + DiagnosticConnectionMethod(rawValue: raw).map(displayName) + ?? unknownPayloadName(raw) + } + private func unknownPayloadName(_ raw: Int) -> String { localized( "diagnostics.unknown.payload", @@ -762,7 +779,6 @@ public struct DiagnosticEventPresentation: Sendable { .displayWorkspacePreviewLinesChanged, .terminalScrollbackRowsChanged, .telemetrySharingChanged, - .connectionMethodPreferenceChanged, .notificationPreferenceChanged, .terminalDraftStateChanged, ] @@ -1388,6 +1404,7 @@ public struct DiagnosticEventPresentation: Sendable { case "active_sessions": localized("diagnostics.field.activeSessions", defaultValue: "Active sessions") case "count": localized("diagnostics.field.count", defaultValue: "Count") case "value": localized("diagnostics.field.value", defaultValue: "Value") + case "method": localized("diagnostics.field.method", defaultValue: "Method") case "action": localized("diagnostics.field.action", defaultValue: "Action") case "tab": localized("diagnostics.field.tab", defaultValue: "Tab") case "scope": localized("diagnostics.field.scope", defaultValue: "Scope") diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift index 028e722f640a..83bba3ce4f52 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift @@ -767,6 +767,7 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable { case displayWorkspacePreviewLinesChanged = 528 case terminalScrollbackRowsChanged = 529 case telemetrySharingChanged = 530 + /// `c`: ``DiagnosticConnectionMethod`` the user switched to. case connectionMethodPreferenceChanged = 531 /// Detail: ``DiagnosticAppEventDetail/toolbarConfigurationAction(_:)``. case customToolbarChanged = 532 @@ -836,6 +837,22 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable { // MARK: Appended persistence events case pairedMacStoreWriteStarted = 660 + + // MARK: Appended connection reporting events + /// The configured connection method, recorded at composition and on every + /// foreground so any shared report window states it even after the ring + /// rolls past app launch. `c`: ``DiagnosticConnectionMethod``. + case connectionMethodConfigured = 661 + /// The transport that actually carries the foreground connection, recorded + /// on connect and on every active-route change. `c`: ``DiagnosticTransportKind``. + case foregroundTransportSelected = 662 +} + +/// The user's configured connection method, mirrored from the settings picker +/// without account, address, or grant details. +public enum DiagnosticConnectionMethod: Int, Sendable, Codable, CaseIterable { + case automatic = 0 + case tailscale = 1 } /// High-level lifecycle state for one phone-controlled Simulator stream. diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/Resources/Localizable.xcstrings b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/Resources/Localizable.xcstrings index 1017928e4b31..614c7de060d8 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/Resources/Localizable.xcstrings +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/Resources/Localizable.xcstrings @@ -103,6 +103,40 @@ } } }, + "diagnostics.connectionMethod.automatic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-Connect (Iroh)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動接続 (Iroh)" + } + } + } + }, + "diagnostics.connectionMethod.tailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tailscale Only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscaleのみ" + } + } + } + }, "diagnostics.count.bytes": { "extractionState": "manual", "localizations": { @@ -5845,6 +5879,23 @@ } } }, + "diagnostics.field.method": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Method" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続方法" + } + } + } + }, "diagnostics.field.publicPaths": { "extractionState": "manual", "localizations": { diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift index 0e7d359d2994..96ff6a874a0e 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift @@ -595,6 +595,48 @@ import Testing } } + /// A shared report must state the configured connection method and the + /// transport actually carrying the foreground connection in words, so a + /// support thread never needs a Settings screenshot to interpret dials. + @Test func describesConnectionMethodAndForegroundTransport() { + let configured = englishPresentation.describe(DiagnosticEvent( + code: .appFeatureAction, + tNanos: 1, + a: DiagnosticAppEventKind.connectionMethodConfigured.rawValue, + c: DiagnosticConnectionMethod.tailscale.rawValue + )) + #expect(configured.fields == [ + .init(key: "operation", value: "connectionMethodConfigured"), + .init(key: "method", value: "Tailscale Only"), + ]) + #expect(englishPresentation.summary(configured) + .contains("Method: Tailscale Only")) + + let changed = englishPresentation.describe(DiagnosticEvent( + code: .appFeatureAction, + tNanos: 1, + a: DiagnosticAppEventKind.connectionMethodPreferenceChanged.rawValue, + c: DiagnosticConnectionMethod.automatic.rawValue + )) + #expect(changed.fields == [ + .init(key: "operation", value: "connectionMethodPreferenceChanged"), + .init(key: "method", value: "Auto-Connect (Iroh)"), + ]) + + let transport = englishPresentation.describe(DiagnosticEvent( + code: .appFeatureAction, + tNanos: 1, + a: DiagnosticAppEventKind.foregroundTransportSelected.rawValue, + c: DiagnosticTransportKind.tailscale.rawValue + )) + #expect(transport.fields == [ + .init(key: "operation", value: "foregroundTransportSelected"), + .init(key: "transport", value: "Tailscale"), + ]) + #expect(englishPresentation.summary(transport) + .contains("Transport: Tailscale")) + } + @Test func extractsFailureAndTransportKinds() { let event = DiagnosticEvent( code: .endpointFailed, diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index dc6c941b9832..bbd0e8a662c6 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -198,6 +198,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { correlationID: foregroundMacDeviceID, count: connectionState == .connected ? 1 : 0 ) + recordForegroundTransportSelected() if connectionState == .connected { restartTerminalLanesForMountedSurfaces() browserStreamEvents?.setBrowserStreamConnectionStatus(.connected) @@ -279,9 +280,26 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { public internal(set) var activeRoute: CmxAttachRoute? { didSet { guard oldValue != activeRoute, connectionState == .connected else { return } + recordForegroundTransportSelected() restartTerminalLanesForMountedSurfaces() } } + + /// Records which transport actually carries the foreground connection, so + /// a shared report states Iroh vs Tailscale usage explicitly instead of + /// leaving it implied by whichever dial events survived the ring. + /// + /// Hooked to both the connected transition and active-route changes: some + /// connect flows pin the route before flipping the state and others after, + /// and a mid-connection promotion swaps the route with no state change. + private func recordForegroundTransportSelected() { + guard connectionState == .connected, let route = activeRoute else { return } + recordAppEvent( + .foregroundTransportSelected, + correlationID: foregroundMacDeviceID, + count: DiagnosticTransportKind(route.kind).rawValue + ) + } /// Authenticated Mac app-instance identity for the foreground connection. /// `nil` only for a fresh/legacy host that has not reported one. var activeMacInstanceTag: String? diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileForegroundTransportDiagnosticsTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileForegroundTransportDiagnosticsTests.swift new file mode 100644 index 000000000000..5fa98fd2fc03 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileForegroundTransportDiagnosticsTests.swift @@ -0,0 +1,85 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxMobileShell + +/// The diagnostics timeline must state which transport actually carries the +/// foreground connection, both at connect and when the route is swapped +/// mid-connection, so shared reports distinguish Iroh from Tailscale usage +/// without inferring it from surviving dial events. +@MainActor +@Suite struct MobileForegroundTransportDiagnosticsTests { + @Test func connectAndRouteChangeRecordSelectedTransport() async throws { + let log = DiagnosticLog(capacity: 16, role: .mobileClient) + let store = MobileShellComposite( + isSignedIn: true, + diagnosticLog: log + ) + let tailscale = try CmxAttachRoute( + id: "granted-tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.42", port: 56_584) + ) + let iroh = try CmxAttachRoute( + id: "iroh-route", + kind: .iroh, + endpoint: .peer( + identity: CmxIrohPeerIdentity( + endpointID: String(repeating: "a", count: 64) + ), + pathHints: [] + ) + ) + + store.connectionState = .connected + store.activeRoute = tailscale + store.activeRoute = iroh + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + func selectedTransports() async -> [Int] { + await log.snapshot().events + .filter { + $0.code == .appFeatureAction && $0.a + == DiagnosticAppEventKind.foregroundTransportSelected.rawValue + } + .compactMap(\.c) + } + while await selectedTransports().count < 2, clock.now < deadline { + await Task.yield() + } + #expect(await selectedTransports() == [ + DiagnosticTransportKind.tailscale.rawValue, + DiagnosticTransportKind.iroh.rawValue, + ]) + } + + @Test func disconnectedRouteChangesRecordNothing() async throws { + let log = DiagnosticLog(capacity: 16, role: .mobileClient) + let store = MobileShellComposite( + isSignedIn: true, + diagnosticLog: log + ) + let tailscale = try CmxAttachRoute( + id: "granted-tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.42", port: 56_584) + ) + + store.activeRoute = tailscale + + // A directly recorded sentinel bounds the drain wait: once it has been + // processed, any transport event recorded before it would be visible. + log.recordAppEvent(.appLaunched) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while await log.processedCount() < 1, clock.now < deadline { + await Task.yield() + } + let selected = await log.snapshot().events.filter { + $0.code == .appFeatureAction && $0.a + == DiagnosticAppEventKind.foregroundTransportSelected.rawValue + } + #expect(selected.isEmpty) + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift index 0d8eafdf9a94..41da1128e4a1 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift @@ -59,6 +59,20 @@ public final class MobileConnectionMethodStore { } else { self.method = .automatic } + recordConfiguredMethodDiagnostic() + } + + /// Records the currently configured method into the diagnostics ring. + /// + /// Called at composition and on every foreground so any shared report + /// window states the configuration even after the bounded ring has rolled + /// past app launch; `connectionMethodPreferenceChanged` alone only marks + /// transitions. + public func recordConfiguredMethodDiagnostic() { + diagnosticLog?.recordAppEvent( + .connectionMethodConfigured, + count: method == .automatic ? 0 : 1 + ) } /// Observes connection-method changes, beginning with the current method. diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift index 928af8f298b8..0b2df2caf607 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift @@ -48,12 +48,48 @@ import Testing let clock = ContinuousClock() let deadline = clock.now.advanced(by: .seconds(1)) - while await log.processedCount() < 1, clock.now < deadline { + while await log.processedCount() < 2, clock.now < deadline { await Task.yield() } - #expect(await log.processedCount() >= 1) - let event = await log.snapshot().events.first - #expect(event?.a == DiagnosticAppEventKind.connectionMethodPreferenceChanged.rawValue) - #expect(event?.c == 1) + #expect(await log.processedCount() >= 2) + let events = await log.snapshot().events + #expect(events.first?.a + == DiagnosticAppEventKind.connectionMethodConfigured.rawValue) + #expect(events.first?.c == 0) + let change = events.last + #expect(change?.a + == DiagnosticAppEventKind.connectionMethodPreferenceChanged.rawValue) + #expect(change?.c == 1) + } + + /// A shared report window must state the configured method even when the + /// bounded ring rolled past app launch, so the configured-method event is + /// re-recordable on demand (the composition root calls it per foreground). + @Test func recordsConfiguredMethodAtInitAndOnDemand() async { + let defaults = makeDefaults() + defaults.set( + MobileConnectionMethod.tailscale.rawValue, + forKey: MobileConnectionMethodStore.methodKey + ) + let log = DiagnosticLog(capacity: 4) + let store = MobileConnectionMethodStore( + defaults: defaults, + diagnosticLog: log + ) + + store.recordConfiguredMethodDiagnostic() + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while await log.processedCount() < 2, clock.now < deadline { + await Task.yield() + } + let events = await log.snapshot().events + #expect(events.count == 2) + for event in events { + #expect(event.a + == DiagnosticAppEventKind.connectionMethodConfigured.rawValue) + #expect(event.c == 1) + } } } diff --git a/ios/cmux/AppCompositionRoot.swift b/ios/cmux/AppCompositionRoot.swift index c516677cba87..a3ff67c701bc 100644 --- a/ios/cmux/AppCompositionRoot.swift +++ b/ios/cmux/AppCompositionRoot.swift @@ -340,6 +340,7 @@ final class AppCompositionRoot { switch phase { case .active: diagnosticLog.recordAppEvent(.appForegrounded) + connectionMethodStore.recordConfiguredMethodDiagnostic() let isFullForegroundReturn = iroh.didBecomeActive() // A notification-permission prompt is itself a transient inactive // edge, so readiness still observes every active transition. From 3f2b88f307b45b3d0581afbcc91b39800cf95923 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:29:20 -0700 Subject: [PATCH 2/2] refactor(ios): map connection method to its diagnostics enum exhaustively Review follow-up: replace the 0/1 ternaries with an exhaustive switch into DiagnosticConnectionMethod so a future third method becomes a compile error instead of silently reporting as Tailscale. Co-Authored-By: Claude Fable 5 --- .../MobileConnectionMethodStore.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift index 41da1128e4a1..720432d79b13 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift @@ -13,6 +13,17 @@ public enum MobileConnectionMethod: String, CaseIterable, Sendable { case tailscale } +extension MobileConnectionMethod { + /// Exhaustive mapping into the diagnostics payload enum, so a future third + /// method becomes a compile error here instead of silently misreporting. + var diagnosticMethod: DiagnosticConnectionMethod { + switch self { + case .automatic: .automatic + case .tailscale: .tailscale + } + } +} + /// Persists the user's connection-method choice. /// /// The choice is exclusive: `automatic` uses the built-in encrypted transport, @@ -41,7 +52,7 @@ public final class MobileConnectionMethodStore { defaults.set(method.rawValue, forKey: Self.methodKey) diagnosticLog?.recordAppEvent( .connectionMethodPreferenceChanged, - count: method == .automatic ? 0 : 1 + count: method.diagnosticMethod.rawValue ) for continuation in continuations.values { continuation.yield(method) @@ -71,7 +82,7 @@ public final class MobileConnectionMethodStore { public func recordConfiguredMethodDiagnostic() { diagnosticLog?.recordAppEvent( .connectionMethodConfigured, - count: method == .automatic ? 0 : 1 + count: method.diagnosticMethod.rawValue ) }