Merge pull request #10310 from manaflow-ai/feat-conn-method-diagnostics

feat(ios): state connection method and live transport in diagnostics reports
This commit is contained in:
Abdulaziz Albahar
2026-08-18 20:29:53 -07:00
committed by GitHub
9 changed files with 299 additions and 7 deletions
@@ -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")
@@ -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.
@@ -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": {
@@ -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,
@@ -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?
@@ -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)
}
}
@@ -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)
@@ -59,6 +70,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.diagnosticMethod.rawValue
)
}
/// Observes connection-method changes, beginning with the current method.
@@ -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)
}
}
}
+1
View File
@@ -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.