Skip to content
Closed
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 @@ -27,6 +27,7 @@ extension MobileShellComposite {
instanceTagUpdate: PairedMacInstanceTagUpdate = .preserve,
displayNameOverride: String? = nil,
userAuthorizedTailscaleRoutes: [CmxAttachRoute] = [],
markActive: Bool = true,
ifStillCurrent: (() -> Bool)? = nil
) async -> Bool {
guard let pairedMacStore,
Expand Down Expand Up @@ -102,14 +103,15 @@ extension MobileShellComposite {
ticketRoutes: ticket.routes, storedRoutes: storedRoutes
)
: ticket.routes
let shouldMarkActive = markActive || existing?.isActive == true
do {
if case .preserveOnlyIfUnclaimed = instanceTagUpdate {
accepted = try await pairedMacStore.upsertRoutesIfAuthorized(
macDeviceID: ticket.macDeviceID,
displayName: displayName,
routes: routes,
condition: .unclaimed,
markActive: true,
markActive: shouldMarkActive,
stackUserID: stackUserID,
teamID: scope?.teamID,
now: Date()
Expand All @@ -121,7 +123,7 @@ extension MobileShellComposite {
displayName: displayName,
routes: routes,
instanceTag: instanceTag,
markActive: true,
markActive: shouldMarkActive,
stackUserID: stackUserID,
teamID: scope?.teamID,
now: Date()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import CmuxMobilePairedMac
import Foundation

private enum ZeroTouchSecondaryAuthenticationOutcome {
case persisted
case transientFailure
case discarded
case superseded
}

@MainActor
extension MobileShellComposite {
/// Limits one automatic launch pass so stale live registrations cannot make
Expand Down Expand Up @@ -54,4 +61,160 @@ extension MobileShellComposite {
}
return candidates
}

/// Retains every discovered app instance except the foreground winner.
/// Broker data is only a dial hint; each row still has to prove its device
/// and build identity over an authenticated RPC before it is persisted.
func stageZeroTouchIrohCandidatesForSecondaryAuthentication(
_ candidates: [MobilePairedMac],
scope: MobileShellScopeSnapshot
) {
guard multiMacAggregationEnabled else { return }
if pendingZeroTouchIrohCandidateScope != scope {
pendingZeroTouchIrohCandidatesByPairingID = [:]
pendingZeroTouchIrohCandidateScope = scope
}
let foregroundPairingID = foregroundMacDeviceID.map {
MobilePairedMac.pairingID(
macDeviceID: $0,
instanceTag: activeMacInstanceTag
)
}
for candidate in candidates where candidate.id != foregroundPairingID {
pendingZeroTouchIrohCandidatesByPairingID[candidate.id] = candidate
}
if pendingZeroTouchIrohCandidatesByPairingID.isEmpty {
pendingZeroTouchIrohCandidateScope = nil
}
}

/// Authenticates and saves sibling zero-touch candidates without replacing
/// the foreground connection. Successful rows become ordinary secondary
/// aggregation targets on the same pass.
func authenticatePendingZeroTouchIrohCandidates(
scope: MobileShellScopeSnapshot
) async {
guard let pairedMacStore,
pendingZeroTouchIrohCandidateScope == scope,
!pendingZeroTouchIrohCandidatesByPairingID.isEmpty else {
return
}
let storedPairingIDs = Set(
(try? await pairedMacStore.loadAll(
stackUserID: scope.userID,
teamID: scope.teamID
))?.map(\.id) ?? []
)
guard !Task.isCancelled, await isScopeCurrent(scope) else { return }

var didPersistCandidate = false
var transientFailureMacIDs: Set<String> = []
let foregroundPairingID = foregroundMacDeviceID.map {
MobilePairedMac.pairingID(
macDeviceID: $0,
instanceTag: activeMacInstanceTag
)
}
let candidates = pendingZeroTouchIrohCandidatesByPairingID.values
.sorted {
if $0.lastSeenAt != $1.lastSeenAt {
return $0.lastSeenAt > $1.lastSeenAt
}
return $0.id < $1.id
}
candidateLoop: for candidate in candidates {
guard !Task.isCancelled, await isScopeCurrent(scope) else {
break candidateLoop
}
guard pendingZeroTouchIrohCandidatesByPairingID[candidate.id]
== candidate else {
continue
}
switch await authenticateZeroTouchIrohCandidate(
candidate,
storedPairingIDs: storedPairingIDs,
foregroundPairingID: foregroundPairingID,
scope: scope
) {
case .persisted:
didPersistCandidate = true
pendingZeroTouchIrohCandidatesByPairingID[candidate.id] = nil
case .discarded:
pendingZeroTouchIrohCandidatesByPairingID[candidate.id] = nil
case .transientFailure:
transientFailureMacIDs.insert(
candidate.macDeviceID
)
case .superseded:
break candidateLoop
}
}
if pendingZeroTouchIrohCandidatesByPairingID.isEmpty {
pendingZeroTouchIrohCandidateScope = nil
}
if didPersistCandidate {
await loadPairedMacs()
}
if !transientFailureMacIDs.isEmpty {
scheduleSecondaryAggregationRetry(
macDeviceIDs: transientFailureMacIDs
)
}
}

private func authenticateZeroTouchIrohCandidate(
_ candidate: MobilePairedMac,
storedPairingIDs: Set<String>,
foregroundPairingID: String?,
scope: MobileShellScopeSnapshot
) async -> ZeroTouchSecondaryAuthenticationOutcome {
guard !storedPairingIDs.contains(candidate.id),
candidate.id != foregroundPairingID,
await !isHiddenMacDeviceID(
candidate.macDeviceID,
instanceTag: candidate.instanceTag,
scope: scope
) else {
return .discarded
}
let handle: SecondaryClientHandle
switch await makeSecondaryClient(for: candidate) {
case let .connected(connectedHandle):
handle = connectedHandle
case .transientFailure:
return .transientFailure
case .permanentFailure:
return .discarded
}
let client = handle.client
guard !Task.isCancelled, await isScopeCurrent(scope) else {
await client.disconnect()
return .superseded
}
guard macBuildIsCompatible(
instanceTag: handle.authenticatedInstanceTag
),
await !isHiddenMacDeviceID(
candidate.macDeviceID,
instanceTag: candidate.instanceTag,
scope: scope
) else {
await client.disconnect()
return .discarded
}
let accepted = await persistPairedMacFromTicket(
handle.ticket,
instanceTagUpdate: .replace(handle.authenticatedInstanceTag),
displayNameOverride: handle.authenticatedDisplayName,
markActive: false,
ifStillCurrent: { [weak self] in
self?.pendingZeroTouchIrohCandidateScope == scope
}
)
await client.disconnect()
guard !Task.isCancelled, await isScopeCurrent(scope) else {
return .superseded
}
return accepted ? .persisted : .transientFailure
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
private var secondaryAggregationTask: Task<Void, Never>?
private var secondaryAggregationTaskGeneration = UUID()
private var secondaryAggregationPending = false
/// Live zero-touch rows that still need endpoint authentication before
/// they can enter the paired store. The existing aggregation task owns
/// these dials so sign-out and account changes cancel the whole operation.
@ObservationIgnored
var pendingZeroTouchIrohCandidatesByPairingID:
[String: MobilePairedMac] = [:]
@ObservationIgnored
var pendingZeroTouchIrohCandidateScope: MobileShellScopeSnapshot?
/// Incremental presence edges are reconciled only for their affected Macs.
/// One coalesced task drains the pending id set without widening each edge
/// into a pool-wide workspace refresh.
Expand Down Expand Up @@ -2610,6 +2618,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
attemptedAutomaticIroh {
recordTransientAutomaticReconnectBackoff(accountID: scope.userID)
}
if connectionState == .connected {
stageZeroTouchIrohCandidatesForSecondaryAuthentication(
zeroTouchCandidates,
scope: scope
)
}
return connectionState == .connected ? .connected : lastDialOutcome
}

Expand Down Expand Up @@ -4330,6 +4344,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
authenticatedInstanceTag: macInstanceTagAuthority.normalize(
status.macInstanceTag
),
authenticatedDisplayName: status.macDisplayName,
supportedHostCapabilities: capabilities,
actionCapabilities: Self.workspaceActionCapabilities(
from: capabilities,
Expand Down Expand Up @@ -4532,6 +4547,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
await refresher.refreshFromBackup(stackUserID: scope.userID)
}
guard await isAggregationScopeValid(scope) else { return }
if allowsNewConnections {
await authenticatePendingZeroTouchIrohCandidates(scope: scope)
}
guard await isAggregationScopeValid(scope) else { return }
let rawRequestedCanonicalIDs = onlyMacDeviceIDs.map {
Set($0.map(cmxCanonicalDeviceID))
}
Expand Down Expand Up @@ -6296,6 +6315,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
secondaryAggregationTask = nil
secondaryAggregationTaskGeneration = UUID()
secondaryAggregationPending = false
pendingZeroTouchIrohCandidatesByPairingID = [:]
pendingZeroTouchIrohCandidateScope = nil
secondaryPresenceAggregationTask?.cancel()
secondaryPresenceAggregationTask = nil
secondaryPresenceAggregationTaskGeneration = UUID()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ struct SecondaryClientHandle {
let storedInstanceTag: String?
/// Instance identity proven by this client's authenticated host status.
let authenticatedInstanceTag: String?
/// Display name proven by the same authenticated host status response.
let authenticatedDisplayName: String?
let supportedHostCapabilities: Set<String>
let actionCapabilities: MobileWorkspaceActionCapabilities
}
Loading