Skip to content
Draft
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
514 changes: 514 additions & 0 deletions DashWallet/Sources/Models/DashConnect/AppConnectLogin.swift

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ protocol DashConnectDataSource {
/// transition could already have reached Platform — the caller must not
/// offer a retry when it could have.
func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws
func disconnect(id: String) async
/// Turns a connection off. For a one-QR connection this disables the key
/// the app was given on the identity and deletes the published response,
/// and throws when the key could not be disabled — the connection then
/// stays as it was. The connection also stays, without an error, while
/// the app holds a key on another identity of the wallet. For a two-QR connection it only changes the local
/// status: that login derives the same key every time and Platform never
/// re-adds a disabled key, so disabling it would lock the app out for good.
func disconnect(id: String) async throws
func remove(id: String) async
}

Expand Down Expand Up @@ -239,7 +246,7 @@ final class MockDashConnectDataSource: DashConnectDataSource {
DashConnectMockError.stateTransitionNotSupported)
}

func disconnect(id: String) async {
func disconnect(id: String) async throws {
let disconnectedAt = Date()
persistAndSend(subject.value.map { connection in
guard connection.id == id else { return connection }
Expand Down
9 changes: 9 additions & 0 deletions DashWallet/Sources/Models/DashConnect/DashConnectModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,22 @@ enum ConnectionStatus: String, Codable, CaseIterable {
}
}

/// Which login made a connection. It decides what turning it off means:
/// a one-QR connection has a session key on the identity to disable, a
/// two-QR connection does not (see `DashConnectDataSource.disconnect`).
enum DashConnectLoginKind: String, Codable {
case twoQR
case oneQR
}

/// A connected app identified by its stable contract id.
struct DAppConnection: Identifiable, Equatable, Hashable {
let id: String
let name: String
let url: String
let status: ConnectionStatus
let updatedAt: Date
var loginKind: DashConnectLoginKind = .twoQR
}

/// A pending request derived from a scanned login QR code.
Expand Down
9 changes: 7 additions & 2 deletions DashWallet/Sources/Models/DashConnect/DashConnectStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ final class UserDefaultsDashConnectStore: DashConnectStore {
name: row.label,
url: row.url,
status: row.status,
updatedAt: row.updatedAt
updatedAt: row.updatedAt,
loginKind: row.loginKind ?? .twoQR
)
)
}
Expand All @@ -140,7 +141,8 @@ final class UserDefaultsDashConnectStore: DashConnectStore {
label: $0.name,
url: $0.url,
status: $0.status,
updatedAt: $0.updatedAt
updatedAt: $0.updatedAt,
loginKind: $0.loginKind == .twoQR ? nil : $0.loginKind
)
}

Expand All @@ -166,6 +168,9 @@ final class UserDefaultsDashConnectStore: DashConnectStore {
let url: String
let status: ConnectionStatus
let updatedAt: Date
/// Absent for a two-QR connection, which is every row written before
/// the one-QR login existed.
let loginKind: DashConnectLoginKind?
}

private static func base58Decode(_ string: String) -> Data? {
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,11 @@ struct ConnectionRow: View {
}

/// Always reads as on: the row only shows it for an active connection, and
/// only the off direction is reachable from here. Turning it off locally
/// downgrades the connection back to `.approved`; it does not sign the user
/// out of the app, because the wallet has no channel to end that session.
/// only the off direction is reachable from here. What turning it off does
/// depends on the login that made the connection (see
/// `DashConnectDataSource.disconnect`): a one-QR connection has the app's
/// key disabled on the identity and its row removed; a two-QR connection
/// is downgraded to `.approved` locally and the app keeps its keys.
private var activeSwitch: some View {
ZStack {
SwitchView(isOn: .constant(true))
Expand Down
2 changes: 1 addition & 1 deletion DashWallet/Sources/UI/DashConnect/ConnectionsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ struct ConnectionsScreen: View {
content
}

if viewModel.isProcessingStateTransition {
if viewModel.isProcessingStateTransition || viewModel.isDisconnecting {
Color.black.opacity(0.08)
.ignoresSafeArea()

Expand Down
27 changes: 24 additions & 3 deletions DashWallet/Sources/UI/DashConnect/ConnectionsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ final class ConnectionsViewModel: ObservableObject {
@Published var isApproving = false
@Published var isApprovingPurchase = false
@Published var isProcessingStateTransition = false
/// A disconnect that is signing an identity update. Its own flag, not
/// `isProcessingStateTransition`: a `dash-st:` task clears that one when
/// it finishes, which would lift the guard while a disconnect is still out.
@Published private(set) var isDisconnecting = false
@Published var message: ConnectionsScreenMessage?
/// Failure of the last approve attempt, rendered **inside** the approve sheet.
/// A screen-level `.alert` cannot appear over a presented sheet, so routing this
Expand Down Expand Up @@ -128,7 +132,7 @@ final class ConnectionsViewModel: ObservableObject {
return
}

guard !isProcessingStateTransition else {
guard !isProcessingStateTransition, !isDisconnecting else {
message = ConnectionsScreenMessage(
kind: .error,
text: NSLocalizedString("Finish the current DashConnect request first, then try again.",
Expand Down Expand Up @@ -281,8 +285,25 @@ final class ConnectionsViewModel: ObservableObject {
}

func disconnect(_ connection: DAppConnection) {
Task {
await dataSource.disconnect(id: connection.id)
// Disconnecting a one-QR connection signs an identity update, which
// takes seconds. It must not overlap anything else that signs one or
// touches the same records: a second tap, an approval on screen or in
// flight, a request still resolving, a `dash-st:` in progress.
guard !isDisconnecting, !isProcessingStateTransition, !isApproving, !isApprovingPurchase,
!isResolvingRequest, pendingRequest == nil, pendingTokenPurchase == nil else { return }
isDisconnecting = true
Task { [weak self] in
guard let self else { return }
defer { self.isDisconnecting = false }
do {
try await self.dataSource.disconnect(id: connection.id)
} catch {
self.message = ConnectionsScreenMessage(
kind: .error,
text: String(
format: NSLocalizedString("Could not disconnect: %@", comment: "DashConnect"),
error.localizedDescription))
}
}
}

Expand Down
17 changes: 16 additions & 1 deletion DashWalletTests/DashConnect/DashConnectStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ final class DashConnectStoreTests: XCTestCase {
XCTAssertEqual(store.load(), connections)
}

func testRoundTripKeepsWhichLoginMadeAConnection() {
let store = makeStore(network: .testnet, walletIdHex: "wallet-a")
var oneQR = sampleConnection(
id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F", status: .active,
updatedAt: Date(timeIntervalSince1970: 10))
oneQR.loginKind = .oneQR
let twoQR = sampleConnection(
id: "7W6u4NgW63FPUuW8EnTbYzD4KybNQD5n7CUDWydJY234", status: .active,
updatedAt: Date(timeIntervalSince1970: 20))

store.save([oneQR, twoQR])

XCTAssertEqual(store.load().map(\.loginKind), [.oneQR, .twoQR])
}

func testUnknownStatusDropsOnlyThatRow() throws {
let store = makeStore(network: .testnet, walletIdHex: "wallet-a")
let payload: [[String: Any]] = [
Expand Down Expand Up @@ -178,7 +193,7 @@ final class DashConnectStoreTests: XCTestCase {
store: store
)

await first.disconnect(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F")
try await first.disconnect(id: "EWR695MsqPUuW8EnTbYzD4KybNQD5n7CUDWydJYNg63F")

let second = MockDashConnectDataSource(store: store)
XCTAssertEqual(second.connectionsSnapshot.count, 1)
Expand Down
Loading
Loading