Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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",
Expand All @@ -762,7 +779,6 @@ public struct DiagnosticEventPresentation: Sendable {
.displayWorkspacePreviewLinesChanged,
.terminalScrollbackRowsChanged,
.telemetrySharingChanged,
.connectionMethodPreferenceChanged,
.notificationPreferenceChanged,
.terminalDraftStateChanged,
]
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
correlationID: foregroundMacDeviceID,
count: connectionState == .connected ? 1 : 0
)
recordForegroundTransportSelected()
if connectionState == .connected {
restartTerminalLanesForMountedSurfaces()
browserStreamEvents?.setBrowserStreamConnectionStatus(.connected)
Expand Down Expand Up @@ -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?
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Observes connection-method changes, beginning with the current method.
Expand Down
Loading