Skip to content

Commit da3dd82

Browse files
yewreekaclaude
andcommitted
Add the iCloud devices section and Main-device designation to Devices
The Devices screen (Settings > Devices) gains a footer under the paired-installations section ("Devices paired to your account") and a second section ("Other devices in iCloud") listing identities from the iCloud-synced keychain backup that aren't paired to the current account. Tapping one arms the standard initiator pairing sheet with the scan instruction naming that device - the current account stays the main account and the join happens from the other device. Unlike the first-install prompt, the section applies no newer-than-own ordering filter: it is an explicit, user-navigated inventory, not an unsolicited demotion offer. The oldest key on the iCloud account is designated the Main device (ICloudDeviceBackupsSnapshot, backedUpAt as the creation proxy with the same caveats as the prompt's ordering rule): badged on whichever row holds it, and the delete-all confirmation escalates its copy when wiping the main device, since other devices pair through that key. Verified by new structured QA test 46 (first run e16c3876a045, all five criteria pass, runner navigation notes folded into the YAML) and four unit tests for the snapshot builder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 47ddd6e commit da3dd82

14 files changed

Lines changed: 529 additions & 8 deletions

.claude/commands/qa.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ The canonical order (from `qa/SKILL.md` "Run all tests") is:
122122
→ 16 → 17 → 20 → 27 → 28 → 19 → 15 → 34 → 18
123123
```
124124
125-
plus secondary tests `14, 22, 23, 23b, 24, 25, 26, 28b, 29, 30, 31, 32, 33, 43, 44b, 45` slotted where they don't conflict with destructive steps (09, 18). Test 43 (long-message rendering) explodes the shared conversation in teardown, so slot it late in the sequence, after other tests that reuse `shared_conversation_id`. Tests 44b and 45 briefly shut down the primary simulator to clone it (like 03/04), so don't overlap them with tests running on the primary; they otherwise run entirely on their own disposable clones.
125+
plus secondary tests `14, 22, 23, 23b, 24, 25, 26, 28b, 29, 30, 31, 32, 33, 43, 44b, 45, 46` slotted where they don't conflict with destructive steps (09, 18). Test 43 (long-message rendering) explodes the shared conversation in teardown, so slot it late in the sequence, after other tests that reuse `shared_conversation_id`. Tests 44b, 45, and 46 briefly shut down the primary simulator to clone it (like 03/04), so don't overlap them with tests running on the primary; they otherwise run entirely on their own disposable clones.
126126
127127
**What can actually run in parallel:**
128128

Convos/App Settings/AppSettingsViewModel.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ final class AppSettingsViewModel {
1010
private(set) var isDeleting: Bool = false
1111
private(set) var deletionProgress: InboxDeletionProgress?
1212
private(set) var deletionError: Error?
13+
/// Whether this device holds the account's main (oldest) iCloud key.
14+
/// Escalates the delete-all confirmation copy - wiping the main
15+
/// device removes the key other devices pair through.
16+
private(set) var currentDeviceIsMain: Bool = false
1317

1418
// MARK: - Dependencies
1519

@@ -35,6 +39,14 @@ final class AppSettingsViewModel {
3539

3640
// MARK: - Actions
3741

42+
/// Refreshes the main-device designation for the delete-all
43+
/// confirmation. Best-effort - defaults to false on failure so the
44+
/// escalated warning never blocks a legitimate delete.
45+
func refreshMainDeviceStatus() async {
46+
let snapshot = await session.iCloudDeviceBackupsSnapshot()
47+
currentDeviceIsMain = snapshot.currentDeviceIsMain
48+
}
49+
3850
func deleteAllData(onComplete: @escaping () -> Void) {
3951
guard !isDeleting else { return }
4052
prepareForDeletion()

Convos/App Settings/DeleteAllDataView.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ struct DeleteAllDataView: View {
2222
}
2323

2424
var subtitle: String {
25-
"This will permanently delete all conversations on this device, as well as your profile."
25+
let base = "This will permanently delete all conversations on this device, as well as your profile."
26+
guard viewModel.currentDeviceIsMain else { return base }
27+
return base + " This is your main device — the first one created for your account — and other devices pair through it."
2628
}
2729

2830
var body: some View {
@@ -65,6 +67,9 @@ struct DeleteAllDataView: View {
6567
ensureNavigator()
6668
navState.markScreenAppeared()
6769
}
70+
.task {
71+
await viewModel.refreshMainDeviceStatus()
72+
}
6873
.onDisappear {
6974
navigator?.closed(context: navState.closeContext())
7075
}

Convos/Devices/DevicesView.swift

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ struct DevicesView: View {
9292
ForEach(viewModel.devices) { device in
9393
deviceRow(device)
9494
}
95+
} footer: {
96+
Text("Devices paired to your account")
97+
}
98+
99+
if !viewModel.iCloudDevices.isEmpty {
100+
Section {
101+
ForEach(viewModel.iCloudDevices) { backup in
102+
iCloudDeviceRow(backup)
103+
}
104+
} footer: {
105+
Text("Other devices in iCloud")
106+
}
95107
}
96108

97109
Section {
@@ -112,8 +124,43 @@ struct DevicesView: View {
112124
.background(.colorBackgroundRaisedSecondary)
113125
}
114126

127+
private func iCloudDeviceRow(_ backup: PairableDeviceBackup) -> some View {
128+
let pairAction = { viewModel.pairICloudDevice(backup) }
129+
let isMain: Bool = viewModel.mainDeviceInboxId == backup.inboxId
130+
return Button(action: pairAction) {
131+
HStack(spacing: DesignConstants.Spacing.step3x) {
132+
Image(systemName: "icloud")
133+
.foregroundStyle(.colorTextPrimary)
134+
.frame(width: 24)
135+
136+
VStack(alignment: .leading, spacing: 2) {
137+
Text(backup.deviceName ?? DevicesViewModel.shortICloudDeviceName(inboxId: backup.inboxId))
138+
.foregroundStyle(.colorTextPrimary)
139+
140+
if isMain {
141+
Text("Main device")
142+
.font(.caption)
143+
.foregroundStyle(.colorTextSecondary)
144+
}
145+
}
146+
147+
Spacer()
148+
149+
Image(systemName: "chevron.right")
150+
.font(.caption)
151+
.foregroundStyle(.colorTextSecondary)
152+
}
153+
.contentShape(Rectangle())
154+
}
155+
.accessibilityIdentifier("icloud-device-row-\(backup.inboxId)")
156+
}
157+
115158
private func deviceRow(_ device: PairedDevice) -> some View {
116-
HStack(spacing: DesignConstants.Spacing.step3x) {
159+
let caption: String? = {
160+
guard device.isCurrentDevice else { return nil }
161+
return viewModel.currentDeviceIsMain ? "This device · Main device" : "This device"
162+
}()
163+
return HStack(spacing: DesignConstants.Spacing.step3x) {
117164
Image(systemName: "iphone.gen3")
118165
.foregroundStyle(.colorTextPrimary)
119166
.frame(width: 24)
@@ -122,8 +169,8 @@ struct DevicesView: View {
122169
Text(device.name)
123170
.foregroundStyle(.colorTextPrimary)
124171

125-
if device.isCurrentDevice {
126-
Text("This device")
172+
if let caption {
173+
Text(caption)
127174
.font(.caption)
128175
.foregroundStyle(.colorTextSecondary)
129176
}

Convos/Devices/DevicesViewModel.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ struct PairedDevice: Identifiable, Equatable, Sendable {
2626
@MainActor
2727
final class DevicesViewModel {
2828
var devices: [PairedDevice] = []
29+
/// Other identities' private keys found in the iCloud-synced keychain
30+
/// backup - devices on the same iCloud account that are not paired to
31+
/// the current account. Oldest first, so the account's original key
32+
/// leads the section.
33+
var iCloudDevices: [PairableDeviceBackup] = []
34+
/// The inboxId of the oldest key on the iCloud account - the "main"
35+
/// device. Nil when ordering can't be established.
36+
var mainDeviceInboxId: String?
37+
/// Whether the current account holds the main (oldest) key, so the
38+
/// current-device row carries the Main badge.
39+
var currentDeviceIsMain: Bool = false
2940
var isLoading: Bool = false
3041
var showPairingSheet: Bool = false
3142
var pairingViewModel: PairingSheetViewModel?
@@ -99,12 +110,56 @@ final class DevicesViewModel {
99110
if role.isInitiator, let baseline {
100111
await self.broadcastProfileSnapshotsAfterPair(baseline: baseline)
101112
}
113+
// A completed pairing changes the iCloud picture (the
114+
// joined device's separate key is retired when it
115+
// adopts this account), so refresh the section too.
116+
await self.refreshICloudDevices()
102117
}
103118
}
104119
}
105120
Task { @MainActor in
106121
await refreshInstallations(refreshFromNetwork: true)
107122
}
123+
Task { @MainActor in
124+
await refreshICloudDevices()
125+
}
126+
}
127+
128+
/// Loads the iCloud backup inventory for the "Other devices in
129+
/// iCloud" section and the Main-device designation. Re-run alongside
130+
/// installation refreshes so a completed pairing (which retires the
131+
/// adopted identity's separate backup) updates the section.
132+
func refreshICloudDevices() async {
133+
guard let session else { return }
134+
let snapshot = await session.iCloudDeviceBackupsSnapshot()
135+
iCloudDevices = snapshot.otherDevices
136+
mainDeviceInboxId = snapshot.mainDeviceInboxId
137+
currentDeviceIsMain = snapshot.currentDeviceIsMain
138+
}
139+
140+
/// Starts the initiator pairing flow targeted at a specific iCloud
141+
/// device: this account stays the main account, the sheet shows the
142+
/// standard invite (QR/PIN), and the scan instruction names the
143+
/// tapped device. The join itself happens from that device.
144+
func pairICloudDevice(_ backup: PairableDeviceBackup) {
145+
guard pairingViewModel == nil else { return }
146+
let service = pairingServiceFactory()
147+
let vm = PairingSheetViewModel(
148+
pairingService: service,
149+
appGroupIdentifier: appGroupIdentifier,
150+
targetDeviceName: backup.deviceName ?? Self.shortICloudDeviceName(inboxId: backup.inboxId)
151+
)
152+
pairingViewModel = vm
153+
showPairingSheet = true
154+
QAEvent.emit(.pairing, "devices_icloud_pair_tapped", ["inboxId": backup.inboxId])
155+
}
156+
157+
/// Display name for an iCloud backup row: the device name stamped on
158+
/// the backup when the writer had one, otherwise a short
159+
/// tail-fingerprint of the inboxId (mirrors `shortDeviceName`).
160+
static func shortICloudDeviceName(inboxId: String) -> String {
161+
let suffix = inboxId.suffix(6)
162+
return "Device \(suffix)"
108163
}
109164

110165
/// The inbox's currently-known installation IDs (cached, no network

Convos/Devices/PairingSheetView.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,11 @@ struct PairingSheetView: View {
6565

6666
@ViewBuilder
6767
private func qrCodeContent(url: String) -> some View {
68+
let scanInstruction: String = viewModel.targetDeviceName.map {
69+
"Open Convos on \"\($0)\" and scan this code to pair"
70+
} ?? "Scan this code with your new device to pair"
6871
VStack(spacing: DesignConstants.Spacing.step4x) {
69-
Text("Scan this code with your new device to pair")
72+
Text(scanInstruction)
7073
.font(.subheadline)
7174
.foregroundStyle(.colorTextPrimary)
7275
.frame(maxWidth: .infinity, alignment: .leading)

Convos/Devices/PairingSheetViewModel.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ final class PairingSheetViewModel: Identifiable {
6666
private let appGroupIdentifier: String?
6767
private let timeoutInterval: TimeInterval
6868
private let mode: PairingSheetMode
69+
/// When the invite targets a specific iCloud device (the Devices
70+
/// screen's "Other devices in iCloud" section), the sheet's scan
71+
/// instruction names it instead of the generic "your new device".
72+
let targetDeviceName: String?
6973
private(set) var coordinator: PairingCoordinator?
7074
private var joinerDeviceName: String = "New Device"
7175
private var joinerInboxId: String?
@@ -78,12 +82,14 @@ final class PairingSheetViewModel: Identifiable {
7882
pairingService: any PairingServiceProtocol,
7983
timeoutInterval: TimeInterval = 120,
8084
mode: PairingSheetMode = .createInvite,
81-
appGroupIdentifier: String? = nil
85+
appGroupIdentifier: String? = nil,
86+
targetDeviceName: String? = nil
8287
) {
8388
self.pairingService = pairingService
8489
self.appGroupIdentifier = appGroupIdentifier
8590
self.timeoutInterval = timeoutInterval
8691
self.mode = mode
92+
self.targetDeviceName = targetDeviceName
8793
self.secondsRemaining = Int(timeoutInterval)
8894
self.observers = PairingNotificationObservers()
8995
if case .respondToJoinRequest = mode {

ConvosCore/Sources/ConvosCore/Pairing/PairableDeviceBackup.swift

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,98 @@ import Foundation
66
/// the backup's key material stays inside ConvosCore
77
/// (`SessionManager.pairingInviteSlug(forBackupInboxId:expiresAt:)` signs
88
/// with it on demand).
9-
public struct PairableDeviceBackup: Sendable, Equatable {
9+
public struct PairableDeviceBackup: Sendable, Equatable, Identifiable {
1010
public let inboxId: String
1111
public let deviceName: String?
1212
public let backedUpAt: Date?
1313

14+
public var id: String { inboxId }
15+
1416
public init(inboxId: String, deviceName: String?, backedUpAt: Date?) {
1517
self.inboxId = inboxId
1618
self.deviceName = deviceName
1719
self.backedUpAt = backedUpAt
1820
}
1921
}
2022

23+
/// Everything the Devices screen needs from the iCloud-synced backup
24+
/// slot: the current identity's own mirror, every other identity's
25+
/// backup, and which of them is the "main" device - the identity whose
26+
/// key was created first. `backedUpAt` is the best available proxy for
27+
/// key creation (a mirror is written when the identity is saved; pairing
28+
/// re-saves and late backfills can re-stamp it, the same caveat the
29+
/// prompt's ordering rule carries).
30+
///
31+
/// Unlike `PairableDeviceBackup.pairableBackups`, `otherDevices` is not
32+
/// filtered by the newer-than-own ordering rule: that rule exists so the
33+
/// unsolicited first-install prompt never offers a device demotion,
34+
/// whereas the Devices screen is an explicit, user-navigated inventory
35+
/// of every key on the iCloud account.
36+
public struct ICloudDeviceBackupsSnapshot: Sendable, Equatable {
37+
/// The current identity's own mirror, nil when it hasn't been
38+
/// written yet (or the keychain read failed upstream).
39+
public let currentDevice: PairableDeviceBackup?
40+
/// Every other identity's backup, oldest first.
41+
public let otherDevices: [PairableDeviceBackup]
42+
43+
/// The inboxId of the oldest key on the iCloud account, nil when no
44+
/// key carries a timestamp to order by.
45+
public var mainDeviceInboxId: String? {
46+
let candidates = ([currentDevice].compactMap { $0 } + otherDevices)
47+
.filter { $0.backedUpAt != nil }
48+
let oldest = candidates.min { (lhs: PairableDeviceBackup, rhs: PairableDeviceBackup) -> Bool in
49+
(lhs.backedUpAt ?? .distantFuture, lhs.inboxId) < (rhs.backedUpAt ?? .distantFuture, rhs.inboxId)
50+
}
51+
return oldest?.inboxId
52+
}
53+
54+
/// Whether the current identity holds the account's main (oldest)
55+
/// key. False when ordering can't be established.
56+
public var currentDeviceIsMain: Bool {
57+
guard let currentDevice else { return false }
58+
return mainDeviceInboxId == currentDevice.inboxId
59+
}
60+
61+
public init(currentDevice: PairableDeviceBackup?, otherDevices: [PairableDeviceBackup]) {
62+
self.currentDevice = currentDevice
63+
self.otherDevices = otherDevices
64+
}
65+
}
66+
67+
extension ICloudDeviceBackupsSnapshot {
68+
/// Builds the snapshot from raw synced backups. Static and pure so
69+
/// it can be unit tested without a keychain. A nil `currentInboxId`
70+
/// (no identity yet) leaves `currentDevice` nil and treats every
71+
/// backup as another device's.
72+
static func snapshot(
73+
from backups: [KeychainIdentityBackup],
74+
currentInboxId: String?
75+
) -> ICloudDeviceBackupsSnapshot {
76+
let own = backups
77+
.first { $0.inboxId == currentInboxId }
78+
.map { (backup: KeychainIdentityBackup) -> PairableDeviceBackup in
79+
PairableDeviceBackup(
80+
inboxId: backup.inboxId,
81+
deviceName: backup.deviceName,
82+
backedUpAt: backup.backedUpAt
83+
)
84+
}
85+
let others = backups
86+
.filter { $0.inboxId != currentInboxId }
87+
.map { (backup: KeychainIdentityBackup) -> PairableDeviceBackup in
88+
PairableDeviceBackup(
89+
inboxId: backup.inboxId,
90+
deviceName: backup.deviceName,
91+
backedUpAt: backup.backedUpAt
92+
)
93+
}
94+
.sorted { (lhs: PairableDeviceBackup, rhs: PairableDeviceBackup) -> Bool in
95+
(lhs.backedUpAt ?? .distantFuture, lhs.inboxId) < (rhs.backedUpAt ?? .distantFuture, rhs.inboxId)
96+
}
97+
return ICloudDeviceBackupsSnapshot(currentDevice: own, otherDevices: others)
98+
}
99+
}
100+
21101
extension PairableDeviceBackup {
22102
/// Filters raw synced backups down to identities other than
23103
/// `currentInboxId` (a fresh install's own placeholder identity mirrors

ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,21 @@ public extension SessionManager {
11481148
}
11491149
}
11501150

1151+
/// Same slot read as `pairableDeviceBackups`, but shaped for the
1152+
/// Devices screen: includes the current identity's own mirror and
1153+
/// applies no ordering filter (see `ICloudDeviceBackupsSnapshot`).
1154+
/// Best-effort: keychain failures return an empty snapshot.
1155+
func iCloudDeviceBackupsSnapshot() async -> ICloudDeviceBackupsSnapshot {
1156+
do {
1157+
let backups = try await identityStore.loadSyncedBackups()
1158+
let currentInboxId = try identityStore.loadSync()?.inboxId
1159+
return ICloudDeviceBackupsSnapshot.snapshot(from: backups, currentInboxId: currentInboxId)
1160+
} catch {
1161+
Log.warning("SessionManager.iCloudDeviceBackupsSnapshot failed: \(error)")
1162+
return ICloudDeviceBackupsSnapshot(currentDevice: nil, otherDevices: [])
1163+
}
1164+
}
1165+
11511166
/// Signs a pairing invite with the synced backup's private key. The
11521167
/// key never leaves ConvosCore - the caller only receives the slug,
11531168
/// which carries the same authority as a slug minted by the backed-up

ConvosCore/Sources/ConvosCore/Sessions/SessionManagerProtocol.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ public protocol SessionManagerProtocol: AnyObject, Sendable {
4545
/// array when there is nothing to offer.
4646
func pairableDeviceBackups() async -> [PairableDeviceBackup]
4747

48+
/// The full iCloud backup inventory for the Devices screen: the
49+
/// current identity's own mirror plus every other identity's backup,
50+
/// unfiltered by the prompt's ordering rule (see
51+
/// `ICloudDeviceBackupsSnapshot`).
52+
func iCloudDeviceBackupsSnapshot() async -> ICloudDeviceBackupsSnapshot
53+
4854
/// Mints a signed pairing-invite slug on behalf of the backed-up
4955
/// device (its synced private key signs the invite, exactly like the
5056
/// QR flow does on the initiator), so the joiner flow can target that
@@ -263,6 +269,12 @@ extension SessionManagerProtocol {
263269
[]
264270
}
265271

272+
/// Default for conformers without keychain access (test mocks): an
273+
/// empty inventory. The real lookup lives on `SessionManager`.
274+
public func iCloudDeviceBackupsSnapshot() async -> ICloudDeviceBackupsSnapshot {
275+
ICloudDeviceBackupsSnapshot(currentDevice: nil, otherDevices: [])
276+
}
277+
266278
/// Default for conformers without keychain access (test mocks). The
267279
/// real signing path lives on `SessionManager`.
268280
public func pairingInviteSlug(forBackupInboxId inboxId: String, expiresAt: Date) async throws -> String {

0 commit comments

Comments
 (0)