mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 19:29:57 +02:00
feat(apple): expose device history through the core gateway
Adds contact, pairing, and offer models plus the gateway surface the feature models will use. Contacts and offers are endpoint-scoped events with no transfer id, so they get their own coalesced signals. DeviceContact.displayName prefers the local label over the name the peer claims, and carries a short endpoint fingerprint for telling apart devices using the same name.
This commit is contained in:
@@ -81,6 +81,80 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
return responseResult
|
return responseResult
|
||||||
}
|
}
|
||||||
func refresh() async -> Result<Void, Error> { .success(()) }
|
func refresh() async -> Result<Void, Error> { .success(()) }
|
||||||
|
|
||||||
|
// MARK: Device history
|
||||||
|
|
||||||
|
var contactsResult: Result<[DeviceContact], Error> = .success([])
|
||||||
|
var pairings: [PendingPairingModel] = []
|
||||||
|
var offers: [IncomingOfferModel] = []
|
||||||
|
var respondToPairingResult: Result<Bool, Error> = .success(true)
|
||||||
|
/// Ticket handed back when an offer is accepted; nil models a declined one.
|
||||||
|
var offerTicket: String? = "vnd1:offered"
|
||||||
|
var sendToContactResult: Result<Share, Error> = .failure(TestError.unimplemented)
|
||||||
|
var forgetContactResult: Result<Void, Error> = .success(())
|
||||||
|
var blockedResult: Result<[String], Error> = .success([])
|
||||||
|
|
||||||
|
private(set) var allowedDevices: [(endpointId: String, displayName: String?)] = []
|
||||||
|
private(set) var pairingResponses: [(endpointId: String, accepted: Bool)] = []
|
||||||
|
private(set) var offerResponses: [(offerId: String, accepted: Bool)] = []
|
||||||
|
private(set) var forgottenContacts: [String] = []
|
||||||
|
private(set) var forgetAllCount = 0
|
||||||
|
private(set) var blockedContactIds: [String] = []
|
||||||
|
private(set) var unblockedContactIds: [String] = []
|
||||||
|
private(set) var contactLabels: [(endpointId: String, label: String?)] = []
|
||||||
|
private(set) var grantLifetimes: [GrantLifetimeOption] = []
|
||||||
|
private(set) var sentToContacts: [String] = []
|
||||||
|
|
||||||
|
func contacts() async -> Result<[DeviceContact], Error> { contactsResult }
|
||||||
|
func pendingPairings() async -> [PendingPairingModel] { pairings }
|
||||||
|
func pendingOffers() async -> [IncomingOfferModel] { offers }
|
||||||
|
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error> {
|
||||||
|
allowedDevices.append((endpointId, displayName))
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||||
|
pairingResponses.append((endpointId, accepted))
|
||||||
|
if case .success = respondToPairingResult {
|
||||||
|
pairings.removeAll { $0.endpointId == endpointId }
|
||||||
|
}
|
||||||
|
return respondToPairingResult
|
||||||
|
}
|
||||||
|
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||||
|
offerResponses.append((offerId, accepted))
|
||||||
|
offers.removeAll { $0.offerId == offerId }
|
||||||
|
return accepted ? offerTicket : nil
|
||||||
|
}
|
||||||
|
func sendToContact(
|
||||||
|
endpointId: String,
|
||||||
|
sources: [ShareSource],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String
|
||||||
|
) async -> Result<Share, Error> {
|
||||||
|
sentToContacts.append(endpointId)
|
||||||
|
return sendToContactResult
|
||||||
|
}
|
||||||
|
func forgetContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
forgottenContacts.append(endpointId)
|
||||||
|
return forgetContactResult
|
||||||
|
}
|
||||||
|
func forgetAllContacts() async -> Result<UInt64, Error> {
|
||||||
|
forgetAllCount += 1
|
||||||
|
return .success(0)
|
||||||
|
}
|
||||||
|
func blockContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
blockedContactIds.append(endpointId)
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
func unblockContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
unblockedContactIds.append(endpointId)
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
func blockedContacts() async -> Result<[String], Error> { blockedResult }
|
||||||
|
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error> {
|
||||||
|
contactLabels.append((endpointId, label))
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async { grantLifetimes.append(lifetime) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal.
|
/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal.
|
||||||
|
|||||||
@@ -47,4 +47,30 @@ protocol CoreGateway: AnyObject {
|
|||||||
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
|
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
|
||||||
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
|
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
|
||||||
func refresh() async -> Result<Void, Error>
|
func refresh() async -> Result<Void, Error>
|
||||||
|
|
||||||
|
// MARK: Device history
|
||||||
|
|
||||||
|
func contacts() async -> Result<[DeviceContact], Error>
|
||||||
|
func pendingPairings() async -> [PendingPairingModel]
|
||||||
|
func pendingOffers() async -> [IncomingOfferModel]
|
||||||
|
/// Hand a device a revocable capability to reach this one.
|
||||||
|
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error>
|
||||||
|
/// Accept or decline a device's offer to be remembered.
|
||||||
|
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error>
|
||||||
|
/// Answer an incoming offer. Returns the ticket on acceptance, which the
|
||||||
|
/// caller passes to `receive` with a platform-appropriate destination.
|
||||||
|
func respondToOffer(offerId: String, accepted: Bool) async -> String?
|
||||||
|
func sendToContact(
|
||||||
|
endpointId: String,
|
||||||
|
sources: [ShareSource],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String
|
||||||
|
) async -> Result<Share, Error>
|
||||||
|
func forgetContact(endpointId: String) async -> Result<Void, Error>
|
||||||
|
func forgetAllContacts() async -> Result<UInt64, Error>
|
||||||
|
func blockContact(endpointId: String) async -> Result<Void, Error>
|
||||||
|
func unblockContact(endpointId: String) async -> Result<Void, Error>
|
||||||
|
func blockedContacts() async -> Result<[String], Error>
|
||||||
|
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error>
|
||||||
|
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ enum CoreSignal: Equatable, Sendable {
|
|||||||
case receiverHistoryChanged(transferId: UInt64)
|
case receiverHistoryChanged(transferId: UInt64)
|
||||||
/// Transfer status/history changed enough to re-read the durable snapshot.
|
/// Transfer status/history changed enough to re-read the durable snapshot.
|
||||||
case transfersChanged(transferId: UInt64)
|
case transfersChanged(transferId: UInt64)
|
||||||
|
/// Device history changed: a contact was added, forgotten, or blocked.
|
||||||
|
case contactsChanged
|
||||||
|
/// An incoming offer arrived or was answered.
|
||||||
|
case offersChanged
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transfer helpers (ported from AppUiModels.kt)
|
// MARK: - Transfer helpers (ported from AppUiModels.kt)
|
||||||
@@ -186,3 +190,91 @@ extension TransferStatus {
|
|||||||
self == .done || self == .failed || self == .cancelled
|
self == .done || self == .failed || self == .cancelled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device history
|
||||||
|
|
||||||
|
/// A device the user has chosen to remember.
|
||||||
|
///
|
||||||
|
/// `localLabel` is the user's own name for the device and is authoritative for
|
||||||
|
/// display; `remoteDisplayName` is whatever the device last called itself and is
|
||||||
|
/// untrusted. The endpoint id is the only real identity.
|
||||||
|
struct DeviceContact: Equatable, Identifiable, Sendable {
|
||||||
|
let endpointId: String
|
||||||
|
let localLabel: String?
|
||||||
|
let remoteDisplayName: String?
|
||||||
|
let lastTransferAt: Int64?
|
||||||
|
let createdAt: Int64
|
||||||
|
/// Whether a live grant is held. False once the peer revoked, the grant
|
||||||
|
/// lapsed, or the peer reinstalled and lost its identity.
|
||||||
|
let canSend: Bool
|
||||||
|
|
||||||
|
var id: String { endpointId }
|
||||||
|
|
||||||
|
/// Name to show, preferring the local label the peer cannot influence.
|
||||||
|
var displayName: String {
|
||||||
|
if let localLabel, !localLabel.isEmpty { return localLabel }
|
||||||
|
if let remoteDisplayName, !remoteDisplayName.isEmpty { return remoteDisplayName }
|
||||||
|
return String(localized: L10n.Approval.nearbyDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short prefix of the endpoint id, for telling apart devices claiming the
|
||||||
|
/// same name.
|
||||||
|
var shortFingerprint: String { String(endpointId.prefix(8)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device offering to be remembered, awaiting this user's decision.
|
||||||
|
struct PendingPairingModel: Equatable, Identifiable, Sendable {
|
||||||
|
let endpointId: String
|
||||||
|
let displayName: String?
|
||||||
|
let receivedAt: Int64
|
||||||
|
|
||||||
|
var id: String { endpointId }
|
||||||
|
|
||||||
|
var resolvedName: String {
|
||||||
|
guard let displayName, !displayName.isEmpty else {
|
||||||
|
return String(localized: L10n.Approval.nearbyDevice)
|
||||||
|
}
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer a remembered device is offering. Carries no ticket: that is a
|
||||||
|
/// capability and the core releases it only once the user accepts.
|
||||||
|
struct IncomingOfferModel: Equatable, Identifiable, Sendable {
|
||||||
|
let offerId: String
|
||||||
|
let fromEndpointId: String
|
||||||
|
let senderDisplayName: String?
|
||||||
|
let transferName: String
|
||||||
|
let fileCount: UInt64
|
||||||
|
let totalBytes: UInt64
|
||||||
|
let receivedAt: Int64
|
||||||
|
|
||||||
|
var id: String { offerId }
|
||||||
|
|
||||||
|
var resolvedSenderName: String {
|
||||||
|
guard let senderDisplayName, !senderDisplayName.isEmpty else {
|
||||||
|
return String(localized: L10n.Approval.nearbyDevice)
|
||||||
|
}
|
||||||
|
return senderDisplayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a remembered device stays reachable while unused. The countdown
|
||||||
|
/// restarts on every transfer.
|
||||||
|
enum GrantLifetimeOption: String, CaseIterable, Identifiable, Sendable {
|
||||||
|
case days30
|
||||||
|
case days90
|
||||||
|
case days365
|
||||||
|
case never
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var days: Int? {
|
||||||
|
switch self {
|
||||||
|
case .days30: return 30
|
||||||
|
case .days90: return 90
|
||||||
|
case .days365: return 365
|
||||||
|
case .never: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -293,6 +293,102 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device history
|
||||||
|
|
||||||
|
func contacts() async -> Result<[DeviceContact], Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().listContacts().map { $0.toModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingPairings() async -> [PendingPairingModel] {
|
||||||
|
let result = await runCore { try self.requireCore().listPendingPairings().map { $0.toModel() } }
|
||||||
|
return (try? result.get()) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingOffers() async -> [IncomingOfferModel] {
|
||||||
|
let result = await runCore { try self.requireCore().listPendingOffers().map { $0.toModel() } }
|
||||||
|
return (try? result.get()) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().allowDeviceToReachMe(endpointId: endpointId, displayName: displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().respondToPairing(endpointId: endpointId, accepted: accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||||
|
let result = await runCore {
|
||||||
|
try self.requireCore().respondToOffer(offerId: offerId, accepted: accepted)
|
||||||
|
}
|
||||||
|
return (try? result.get()) ?? nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendToContact(
|
||||||
|
endpointId: String,
|
||||||
|
sources: [ShareSource],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String
|
||||||
|
) async -> Result<Share, Error> {
|
||||||
|
guard !isNetworkTransitionInProgress else {
|
||||||
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
|
}
|
||||||
|
guard !sources.isEmpty else {
|
||||||
|
return .failure(InvitationError.shareEmpty)
|
||||||
|
}
|
||||||
|
return await runCore {
|
||||||
|
// The access mode is forced to approval-required by the core for
|
||||||
|
// offers; passing it here only keeps the metadata well-formed.
|
||||||
|
let result = try self.requireCore().sendToContact(
|
||||||
|
endpointId: endpointId,
|
||||||
|
sources: sources,
|
||||||
|
metadata: ShareMetadataInput(
|
||||||
|
transferId: Self.nextTransferId(),
|
||||||
|
transferName: transferName.isEmpty ? nil : transferName,
|
||||||
|
senderName: senderName.isEmpty ? nil : senderName,
|
||||||
|
accessMode: .approvalRequired
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.toModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forgetContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
await runCore { try self.requireCore().forgetContact(endpointId: endpointId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func forgetAllContacts() async -> Result<UInt64, Error> {
|
||||||
|
await runCore { try self.requireCore().forgetAllContacts() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
await runCore { try self.requireCore().blockContact(endpointId: endpointId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func unblockContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
await runCore { try self.requireCore().unblockContact(endpointId: endpointId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockedContacts() async -> Result<[String], Error> {
|
||||||
|
await runCore { try self.requireCore().listBlockedContacts() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().setContactLabel(endpointId: endpointId, label: label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async {
|
||||||
|
_ = await runCore { try self.requireCore().setGrantLifetime(lifetime: lifetime.toNative()) }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Event sink handling (ported from CoreRepository.sink)
|
// MARK: - Event sink handling (ported from CoreRepository.sink)
|
||||||
|
|
||||||
private func handle(event: CoreEvent) {
|
private func handle(event: CoreEvent) {
|
||||||
@@ -302,6 +398,14 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
|
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
|
||||||
state.events = events
|
state.events = events
|
||||||
|
|
||||||
|
// Contacts and offers are endpoint-scoped: they carry no transfer id, so
|
||||||
|
// they are dispatched before the transfer-scoped handling below.
|
||||||
|
switch model.phase {
|
||||||
|
case "contacts": signalsSubject.send(.contactsChanged)
|
||||||
|
case "offer": signalsSubject.send(.offersChanged)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
guard let transferId = model.transferId else { return }
|
guard let transferId = model.transferId else { return }
|
||||||
switch model.phase {
|
switch model.phase {
|
||||||
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
|
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
|
||||||
@@ -519,3 +623,47 @@ private extension ReceiverRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ContactSummary {
|
||||||
|
func toModel() -> DeviceContact {
|
||||||
|
DeviceContact(
|
||||||
|
endpointId: endpointId,
|
||||||
|
localLabel: localLabel,
|
||||||
|
remoteDisplayName: remoteDisplayName,
|
||||||
|
lastTransferAt: lastTransferAt,
|
||||||
|
createdAt: createdAt,
|
||||||
|
canSend: canSend
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PendingPairing {
|
||||||
|
func toModel() -> PendingPairingModel {
|
||||||
|
PendingPairingModel(endpointId: endpointId, displayName: displayName, receivedAt: receivedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension IncomingOffer {
|
||||||
|
func toModel() -> IncomingOfferModel {
|
||||||
|
IncomingOfferModel(
|
||||||
|
offerId: offerId,
|
||||||
|
fromEndpointId: fromEndpointId,
|
||||||
|
senderDisplayName: senderDisplayName,
|
||||||
|
transferName: transferName,
|
||||||
|
fileCount: fileCount,
|
||||||
|
totalBytes: totalBytes,
|
||||||
|
receivedAt: receivedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GrantLifetimeOption {
|
||||||
|
func toNative() -> GrantLifetimeSetting {
|
||||||
|
switch self {
|
||||||
|
case .days30: return .days30
|
||||||
|
case .days90: return .days90
|
||||||
|
case .days365: return .days365
|
||||||
|
case .never: return .never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ final class TransferNotificationCoordinator: ObservableObject {
|
|||||||
switch signal {
|
switch signal {
|
||||||
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||||
Task { await self.syncReceivers(transferId: transferId) }
|
Task { await self.syncReceivers(transferId: transferId) }
|
||||||
case .approvalChanged:
|
case .approvalChanged, .contactsChanged, .offersChanged:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ final class SendModel: ObservableObject {
|
|||||||
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
||||||
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
||||||
self.refreshReceiverStatuses(for: id)
|
self.refreshReceiverStatuses(for: id)
|
||||||
|
case .contactsChanged, .offersChanged:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
Reference in New Issue
Block a user