refactor(core): remove prototype contact paths for experimental saved devices

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 06:05:31 +02:00
parent 0a6ecb5c3b
commit a2385912c2
63 changed files with 514 additions and 7806 deletions

View File

@@ -122,14 +122,6 @@ struct AppPreferences: Equatable {
var themeMode: ThemeMode
var diagnosticsInstallId: String
var relayConfiguration: RelayConfiguration
/// Idle lifetime applied to grants this device issues from now on.
var grantLifetime: GrantLifetimeOption
/// Devices the user declined to remember. Persisted so a repeat transfer
/// with the same device does not re-ask forever.
var declinedPairingSuggestions: Set<String>
/// Whether opening the app asks remembered devices for waiting transfers.
/// Off by default: it reveals app-open times to every contact.
var checkForOffersOnOpen: Bool
}
struct AppPreferencesDefaults {
@@ -153,10 +145,7 @@ final class AppPreferencesRepository: ObservableObject {
static let themeMode = "theme_mode"
static let diagnosticsInstallId = "diagnostics_install_id"
static let relayConfiguration = "relay_configuration"
static let grantLifetime = "grant_lifetime"
static let declinedPairingSuggestions = "declined_pairing_suggestions"
static let checkForOffersOnOpen = "check_for_offers_on_open"
}
}
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
self.defaults = defaults
@@ -169,18 +158,12 @@ final class AppPreferencesRepository: ObservableObject {
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
let grantLifetime = defaults.string(forKey: Key.grantLifetime)
.flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90
let declined = Set(defaults.stringArray(forKey: Key.declinedPairingSuggestions) ?? [])
return AppPreferences(
username: username,
receiveFolder: folder,
themeMode: themeMode,
diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults),
grantLifetime: grantLifetime,
declinedPairingSuggestions: declined,
checkForOffersOnOpen: defaults.bool(forKey: Key.checkForOffersOnOpen)
relayConfiguration: resolveRelayConfiguration(defaults)
)
}
@@ -226,32 +209,6 @@ final class AppPreferencesRepository: ObservableObject {
setReceiveFolder(fallback.receiveFolder)
}
func declinePairingSuggestion(_ endpointId: String) {
var declined = preferences.declinedPairingSuggestions
declined.insert(endpointId)
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
reload()
}
/// Clears the decline so the device can be suggested again, used when the
/// user pairs with it deliberately.
func clearDeclinedPairingSuggestion(_ endpointId: String) {
var declined = preferences.declinedPairingSuggestions
guard declined.remove(endpointId) != nil else { return }
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
reload()
}
func setCheckForOffersOnOpen(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.checkForOffersOnOpen)
reload()
}
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
reload()
}
func setThemeMode(_ mode: ThemeMode) {
defaults.set(mode.rawValue, forKey: Key.themeMode)
reload()

View File

@@ -47,42 +47,3 @@ protocol CoreGateway: AnyObject {
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) 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<ContactSendOutcome, Error>
/// Offer an existing share to a remembered device, alongside its QR code.
func offerTransferToContact(
transferId: UInt64,
endpointId: String
) async -> Result<ContactSendOutcome, Error>
/// Transfers this device is holding for contacts that were not running.
func heldOffers() async -> Result<[HeldOfferModel], Error>
/// Ask remembered devices whether they hold anything for this one.
///
/// Only ever called from a foreground transition or an explicit user action:
/// it reveals to every contact that this device is awake.
func pollContactsForOffers() async -> Result<UInt64, 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
}

View File

@@ -74,13 +74,8 @@ enum ShareAccessPolicy: Equatable, Sendable {
}
/// Where a picked selection is going.
///
/// A contact destination deliberately carries no access policy: the core forces
/// approval-required for offers, so exposing the choice here would imply a
/// setting that does not exist.
enum ShareDestination: Equatable, Sendable {
case invitation(accessPolicy: ShareAccessPolicy)
case contact(endpointId: String)
}
enum TransferDirection: Equatable, Sendable {
@@ -179,10 +174,6 @@ enum CoreSignal: Equatable, Sendable {
case receiverHistoryChanged(transferId: UInt64)
/// Transfer status/history changed enough to re-read the durable snapshot.
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)
@@ -201,112 +192,3 @@ extension TransferStatus {
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
}
}
/// A transfer waiting for its target device to come back online.
struct HeldOfferModel: Equatable, Identifiable, Sendable {
let offerId: String
let endpointId: String
let transferId: UInt64
let transferName: String
let fileCount: UInt64
let totalBytes: UInt64
let createdAt: Int64
var id: String { offerId }
}
/// Outcome of sending straight to a remembered device.
struct ContactSendOutcome: Equatable, Sendable {
let share: Share
/// False when the device was not running: the transfer is held locally and
/// collected the next time that device opens the app.
let delivered: Bool
}
/// 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
}
}
}

View File

@@ -293,123 +293,6 @@ final class CoreRepository: ObservableObject, CoreGateway {
if let snapshot { self.applySnapshot(snapshot) }
}
}
// 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<ContactSendOutcome, 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 ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
}
}
func offerTransferToContact(
transferId: UInt64,
endpointId: String
) async -> Result<ContactSendOutcome, Error> {
await runCore {
let result = try self.requireCore().offerTransferToContact(
transferId: transferId, endpointId: endpointId
)
return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
}
}
func heldOffers() async -> Result<[HeldOfferModel], Error> {
await runCore { try self.requireCore().listHeldOffers().map { $0.toModel() } }
}
func pollContactsForOffers() async -> Result<UInt64, Error> {
await runCore { try self.requireCore().pollContactsForOffers() }
}
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)
private func handle(event: CoreEvent) {
@@ -419,14 +302,6 @@ final class CoreRepository: ObservableObject, CoreGateway {
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
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 }
switch model.phase {
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
@@ -645,60 +520,7 @@ 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 HeldOfferSummary {
func toModel() -> HeldOfferModel {
HeldOfferModel(
offerId: offerId,
endpointId: endpointId,
transferId: transferId,
transferName: transferName,
fileCount: fileCount,
totalBytes: totalBytes,
createdAt: createdAt
)
}
}
extension GrantLifetimeOption {
func toNative() -> GrantLifetimeSetting {
switch self {
case .days30: return .days30
case .days90: return .days90
case .days365: return .days365
case .never: return .never
}
}
}

View File

@@ -42,7 +42,7 @@ protocol FileSystemService {
transferName: String,
senderName: String,
destination: ShareDestination
) async -> Result<ContactSendOutcome, Error>
) async -> Result<Share, Error>
}
extension FileSystemService {