mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-12 05:29:57 +02:00
refactor(core): remove prototype contact paths for experimental saved devices
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,640 +0,0 @@
|
||||
import XCTest
|
||||
@testable import VniDrop
|
||||
|
||||
@MainActor
|
||||
final class ContactsModelTests: XCTestCase {
|
||||
private func makeModel(
|
||||
_ gateway: FakeCoreGateway
|
||||
) -> (ContactsModel, AppPreferencesRepository) {
|
||||
let defaults = UserDefaults(suiteName: "contacts-tests-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(
|
||||
kind: .fileSystemPath,
|
||||
value: "/tmp",
|
||||
displayName: "Downloads"
|
||||
),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: FakeFileSystemService()
|
||||
)
|
||||
return (model, preferences)
|
||||
}
|
||||
|
||||
private func contact(
|
||||
_ endpointId: String,
|
||||
label: String? = nil,
|
||||
remoteName: String? = nil,
|
||||
canSend: Bool = true
|
||||
) -> DeviceContact {
|
||||
DeviceContact(
|
||||
endpointId: endpointId,
|
||||
localLabel: label,
|
||||
remoteDisplayName: remoteName,
|
||||
lastTransferAt: nil,
|
||||
createdAt: 0,
|
||||
canSend: canSend
|
||||
)
|
||||
}
|
||||
|
||||
private func offer(_ offerId: String, from endpointId: String = "peer") -> IncomingOfferModel {
|
||||
IncomingOfferModel(
|
||||
offerId: offerId,
|
||||
fromEndpointId: endpointId,
|
||||
senderDisplayName: "Peer",
|
||||
transferName: "photos",
|
||||
fileCount: 2,
|
||||
totalBytes: 1_024,
|
||||
receivedAt: 0
|
||||
)
|
||||
}
|
||||
|
||||
func testRefreshLoadsContactsBlocksAndPrompts() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("a"), contact("b")])
|
||||
gateway.blockedResult = .success(["blocked-one"])
|
||||
gateway.pairings = [PendingPairingModel(endpointId: "c", displayName: "Laptop", receivedAt: 0)]
|
||||
gateway.offers = [offer("offer-1")]
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.state.contacts.count, 2)
|
||||
XCTAssertEqual(model.state.blocked, ["blocked-one"])
|
||||
XCTAssertEqual(model.state.currentPairing?.endpointId, "c")
|
||||
XCTAssertEqual(model.state.currentOffer?.offerId, "offer-1")
|
||||
XCTAssertFalse(model.state.isLoading)
|
||||
}
|
||||
|
||||
/// Accepting an offer is the only path that yields a ticket; the caller needs
|
||||
/// it to run the receive with its own destination.
|
||||
func testAcceptingAnOfferReturnsTheTicket() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offers = [offer("offer-1")]
|
||||
gateway.offerTicket = "vnd1:abc"
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
|
||||
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: true)
|
||||
|
||||
XCTAssertEqual(ticket, "vnd1:abc")
|
||||
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
||||
XCTAssertEqual(gateway.offerResponses.map(\.accepted), [true])
|
||||
}
|
||||
|
||||
func testDecliningAnOfferYieldsNoTicketAndClearsThePrompt() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offers = [offer("offer-1")]
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
|
||||
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: false)
|
||||
|
||||
XCTAssertNil(ticket, "a declined offer must not hand over a capability")
|
||||
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
||||
}
|
||||
|
||||
/// Declining to be remembered must leave nothing behind for the peer.
|
||||
func testDecliningPairingClearsThePromptWithoutAddingAContact() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: nil, receivedAt: 0)]
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
|
||||
await model.respondToPairing(endpointId: "peer", accepted: false)
|
||||
|
||||
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
||||
XCTAssertTrue(model.state.contacts.isEmpty)
|
||||
XCTAssertEqual(gateway.pairingResponses.map(\.accepted), [false])
|
||||
}
|
||||
|
||||
func testAcceptingPairingAddsTheContact() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: "Laptop", receivedAt: 0)]
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
gateway.contactsResult = .success([contact("peer", remoteName: "Laptop")])
|
||||
|
||||
await model.respondToPairing(endpointId: "peer", accepted: true)
|
||||
|
||||
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
||||
XCTAssertEqual(model.state.contacts.map(\.endpointId), ["peer"])
|
||||
}
|
||||
|
||||
func testForgettingClearsTheSelectionAndReloads() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer")])
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
model.select("peer")
|
||||
|
||||
gateway.contactsResult = .success([])
|
||||
await model.forget(endpointId: "peer")
|
||||
|
||||
XCTAssertEqual(gateway.forgottenContacts, ["peer"])
|
||||
XCTAssertNil(model.state.selectedEndpointId)
|
||||
XCTAssertTrue(model.state.contacts.isEmpty)
|
||||
}
|
||||
|
||||
func testBlockingRemovesTheContactAndKeepsItListedAsBlocked() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer")])
|
||||
let (model, _) = makeModel(gateway)
|
||||
await model.refresh()
|
||||
model.select("peer")
|
||||
|
||||
gateway.contactsResult = .success([])
|
||||
gateway.blockedResult = .success(["peer"])
|
||||
await model.block(endpointId: "peer")
|
||||
|
||||
XCTAssertEqual(gateway.blockedContactIds, ["peer"])
|
||||
XCTAssertNil(model.state.selectedEndpointId)
|
||||
XCTAssertEqual(model.state.blocked, ["peer"])
|
||||
}
|
||||
|
||||
/// An empty label clears the override rather than storing whitespace, so the
|
||||
/// row falls back to the name the device reports.
|
||||
func testBlankLabelClearsTheLocalName() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.setLabel(endpointId: "peer", label: " ")
|
||||
|
||||
XCTAssertEqual(gateway.contactLabels.count, 1)
|
||||
XCTAssertNil(gateway.contactLabels[0].label)
|
||||
}
|
||||
|
||||
func testLabelIsTrimmedBeforeStoring() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.setLabel(endpointId: "peer", label: " Work Mac ")
|
||||
|
||||
XCTAssertEqual(gateway.contactLabels[0].label, "Work Mac")
|
||||
}
|
||||
|
||||
/// The core holds the lifetime in memory only, so the stored preference is
|
||||
/// the durable copy and both have to move together.
|
||||
func testGrantLifetimeIsPersistedAndPushedToTheCore() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway)
|
||||
|
||||
model.setGrantLifetime(.days365)
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(model.state.grantLifetime, .days365)
|
||||
XCTAssertEqual(preferences.preferences.grantLifetime, .days365)
|
||||
XCTAssertEqual(gateway.grantLifetimes.last, .days365)
|
||||
}
|
||||
|
||||
func testDefaultGrantLifetimeIsNinetyDays() {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
XCTAssertEqual(model.state.grantLifetime, .days90)
|
||||
}
|
||||
|
||||
/// The local label wins over whatever the peer calls itself.
|
||||
func testDisplayNamePrefersTheLocalLabel() {
|
||||
let subject = contact("peer", label: "Work Mac", remoteName: "Totally Not Evil")
|
||||
|
||||
XCTAssertEqual(subject.displayName, "Work Mac")
|
||||
}
|
||||
|
||||
func testDisplayNameFallsBackToTheReportedName() {
|
||||
let subject = contact("peer", remoteName: "Laptop")
|
||||
|
||||
XCTAssertEqual(subject.displayName, "Laptop")
|
||||
}
|
||||
|
||||
/// Files picked for a device go out as an offer, never as an invitation
|
||||
/// anyone holding the ticket could use.
|
||||
func testSendingToAContactUsesTheContactDestination() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let files = FakeFileSystemService()
|
||||
let defaults = UserDefaults(suiteName: "contacts-send-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: files
|
||||
)
|
||||
gateway.sendToContactResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 1, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: true
|
||||
)
|
||||
)
|
||||
|
||||
model.chooseFilesToSend(to: "peer")
|
||||
XCTAssertTrue(model.pendingFilePick)
|
||||
await model.onFilesPicked([
|
||||
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||
])
|
||||
|
||||
XCTAssertEqual(files.shareDestinations, [.contact(endpointId: "peer")])
|
||||
XCTAssertEqual(gateway.sentToContacts, ["peer"])
|
||||
}
|
||||
|
||||
/// A pick that arrives with no target must not be sent anywhere.
|
||||
func testPickedFilesWithoutATargetAreIgnored() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let files = FakeFileSystemService()
|
||||
let defaults = UserDefaults(suiteName: "contacts-send-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: files
|
||||
)
|
||||
|
||||
await model.onFilesPicked([
|
||||
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||
])
|
||||
|
||||
XCTAssertTrue(files.shareDestinations.isEmpty)
|
||||
XCTAssertTrue(gateway.sentToContacts.isEmpty)
|
||||
}
|
||||
|
||||
/// Polling is opt-in: it tells every contact the app was opened.
|
||||
func testForegroundCheckIsSkippedUnlessEnabled() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.checkForOffersOnForeground()
|
||||
|
||||
XCTAssertEqual(gateway.pollCount, 0)
|
||||
}
|
||||
|
||||
func testForegroundCheckRunsOnceEnabled() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway)
|
||||
|
||||
model.setCheckForOffersOnOpen(true)
|
||||
await model.checkForOffersOnForeground()
|
||||
|
||||
XCTAssertEqual(gateway.pollCount, 1)
|
||||
XCTAssertTrue(preferences.preferences.checkForOffersOnOpen)
|
||||
}
|
||||
|
||||
/// The explicit "check now" ignores the setting: the user just asked.
|
||||
func testExplicitCheckRunsEvenWhenTheSettingIsOff() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.pollResult = .success(2)
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
let collected = await model.collectWaitingOffers()
|
||||
|
||||
XCTAssertEqual(collected, 2)
|
||||
XCTAssertEqual(gateway.pollCount, 1)
|
||||
}
|
||||
|
||||
/// A transfer that could not be delivered is reported as waiting, not as a
|
||||
/// success nobody has received.
|
||||
func testAnUndeliveredSendIsReportedAsWaiting() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let files = FakeFileSystemService()
|
||||
let defaults = UserDefaults(suiteName: "contacts-held-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let messages = UiMessageController()
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: messages,
|
||||
preferences: preferences,
|
||||
fileSystemService: files
|
||||
)
|
||||
gateway.sendToContactResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 1, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: false
|
||||
)
|
||||
)
|
||||
|
||||
model.chooseFilesToSend(to: "peer")
|
||||
await model.onFilesPicked([
|
||||
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||
])
|
||||
|
||||
XCTAssertEqual(messages.current?.tone, .info)
|
||||
}
|
||||
|
||||
func testHeldOffersAreLoadedForDisplay() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.heldOffersResult = .success([
|
||||
HeldOfferModel(
|
||||
offerId: "held-1",
|
||||
endpointId: "peer",
|
||||
transferId: 1,
|
||||
transferName: "doc",
|
||||
fileCount: 1,
|
||||
totalBytes: 2,
|
||||
createdAt: 0
|
||||
)
|
||||
])
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.state.heldOffers.map(\.offerId), ["held-1"])
|
||||
}
|
||||
|
||||
/// Offering an existing transfer reuses it rather than creating another.
|
||||
func testOfferingAnExistingTransferReportsAcceptance() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offerTransferResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 7, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: true
|
||||
)
|
||||
)
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||
|
||||
XCTAssertTrue(delivered)
|
||||
XCTAssertEqual(gateway.offeredTransfers.map(\.transferId), [7])
|
||||
XCTAssertEqual(gateway.offeredTransfers.map(\.endpointId), ["peer"])
|
||||
}
|
||||
|
||||
/// An offer to a closed device is reported as waiting, not accepted.
|
||||
func testOfferingToAClosedDeviceReportsItAsWaiting() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offerTransferResult = .success(
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 7, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: false
|
||||
)
|
||||
)
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||
|
||||
XCTAssertFalse(delivered)
|
||||
}
|
||||
|
||||
/// A refusal by the person on the other device is information, not an error.
|
||||
func testADeclinedOfferIsReportedWithoutAnErrorTone() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offerTransferResult = .failure(
|
||||
InvitationError.raw("permission error: device did not accept the transfer: receiver-declined")
|
||||
)
|
||||
let defaults = UserDefaults(suiteName: "contacts-declined-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let messages = UiMessageController()
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: messages,
|
||||
preferences: preferences,
|
||||
fileSystemService: FakeFileSystemService()
|
||||
)
|
||||
|
||||
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||
|
||||
XCTAssertFalse(delivered)
|
||||
XCTAssertEqual(messages.current?.tone, .info)
|
||||
}
|
||||
|
||||
func testUnreachableContactIsSurfacedForRepairing() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
||||
let (model, _) = makeModel(gateway)
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.state.contacts.first?.canSend, false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post-transfer suggestions
|
||||
|
||||
@MainActor
|
||||
final class PairingSuggestionTests: XCTestCase {
|
||||
private func makeModel(
|
||||
_ gateway: FakeCoreGateway,
|
||||
defaults: UserDefaults
|
||||
) -> (ContactsModel, AppPreferencesRepository) {
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(
|
||||
kind: .fileSystemPath,
|
||||
value: "/tmp",
|
||||
displayName: "Downloads"
|
||||
),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: UiMessageController(),
|
||||
preferences: preferences,
|
||||
fileSystemService: FakeFileSystemService()
|
||||
)
|
||||
return (model, preferences)
|
||||
}
|
||||
|
||||
private func newDefaults() -> UserDefaults {
|
||||
UserDefaults(suiteName: "suggestion-tests-\(UUID().uuidString)")!
|
||||
}
|
||||
|
||||
private func completedReceive(from peerId: String?) -> Transfer {
|
||||
Transfer(
|
||||
localId: "local-1",
|
||||
transferId: 1,
|
||||
direction: .receive,
|
||||
status: .done,
|
||||
peerId: peerId,
|
||||
transferName: "photos",
|
||||
contentHash: nil,
|
||||
fileCount: 1,
|
||||
totalSize: 10,
|
||||
ticket: nil,
|
||||
accessPolicy: .requireApproval,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
)
|
||||
}
|
||||
|
||||
private func state(with transfers: [Transfer]) -> CoreState {
|
||||
var core = CoreState()
|
||||
core.isInitialized = true
|
||||
core.transfers = transfers
|
||||
return core
|
||||
}
|
||||
|
||||
func testCompletedReceiveSuggestsItsSender() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(model.state.currentSuggestion?.endpointId, "sender-endpoint")
|
||||
}
|
||||
|
||||
/// A transfer that never recorded a peer cannot be turned into a suggestion.
|
||||
func testReceiveWithoutAPeerIsNotSuggested() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: nil)]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
func testAlreadyRememberedDeviceIsNotSuggested() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([
|
||||
DeviceContact(
|
||||
endpointId: "sender-endpoint",
|
||||
localLabel: nil,
|
||||
remoteDisplayName: nil,
|
||||
lastTransferAt: nil,
|
||||
createdAt: 0,
|
||||
canSend: true
|
||||
)
|
||||
])
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
func testBlockedDeviceIsNotSuggested() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.blockedResult = .success(["sender-endpoint"])
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
/// Declining has to stick, or every later transfer with the same device
|
||||
/// re-asks the question the user already answered.
|
||||
func testDecliningIsRememberedAcrossLaterTransfers() async {
|
||||
let defaults = newDefaults()
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
||||
await model.refresh()
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
||||
|
||||
model.declineSuggestion(suggestion!)
|
||||
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||
|
||||
// A second transfer with the same device must stay silent.
|
||||
gateway.setState(CoreState())
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
func testAcceptingASuggestionIssuesAGrantUnderTheLocalUsername() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
||||
|
||||
await model.acceptSuggestion(suggestion!)
|
||||
|
||||
XCTAssertEqual(gateway.allowedDevices.map(\.endpointId), ["sender-endpoint"])
|
||||
XCTAssertEqual(gateway.allowedDevices.first?.displayName, "tester")
|
||||
XCTAssertNil(model.state.currentSuggestion)
|
||||
}
|
||||
|
||||
/// Pairing deliberately after declining should work, so the decline is
|
||||
/// cleared rather than blocking the device forever.
|
||||
func testAcceptingClearsAnEarlierDecline() async {
|
||||
let defaults = newDefaults()
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
||||
let suggestion = PairingSuggestion(
|
||||
endpointId: "sender-endpoint",
|
||||
displayName: nil,
|
||||
transferName: nil
|
||||
)
|
||||
model.declineSuggestion(suggestion)
|
||||
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||
|
||||
await model.acceptSuggestion(suggestion)
|
||||
|
||||
XCTAssertFalse(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||
}
|
||||
|
||||
func testTheSameDeviceIsOnlySuggestedOnce() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||
await model.refresh()
|
||||
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(model.state.suggestions.count, 1)
|
||||
}
|
||||
}
|
||||
@@ -81,98 +81,6 @@ final class FakeCoreGateway: CoreGateway {
|
||||
return responseResult
|
||||
}
|
||||
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<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
|
||||
var heldOffersResult: Result<[HeldOfferModel], Error> = .success([])
|
||||
var pollResult: Result<UInt64, Error> = .success(0)
|
||||
private(set) var pollCount = 0
|
||||
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<ContactSendOutcome, Error> {
|
||||
sentToContacts.append(endpointId)
|
||||
return sendToContactResult
|
||||
}
|
||||
private(set) var offeredTransfers: [(transferId: UInt64, endpointId: String)] = []
|
||||
var offerTransferResult: Result<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
|
||||
|
||||
func offerTransferToContact(
|
||||
transferId: UInt64,
|
||||
endpointId: String
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
offeredTransfers.append((transferId, endpointId))
|
||||
return offerTransferResult
|
||||
}
|
||||
func heldOffers() async -> Result<[HeldOfferModel], Error> { heldOffersResult }
|
||||
func pollContactsForOffers() async -> Result<UInt64, Error> {
|
||||
pollCount += 1
|
||||
return pollResult
|
||||
}
|
||||
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.
|
||||
@@ -186,19 +94,14 @@ final class FakeFileSystemService: FileSystemService {
|
||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||
private(set) var shareDestinations: [ShareDestination] = []
|
||||
|
||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<ContactSendOutcome, Error> {
|
||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<Share, Error> {
|
||||
shareDestinations.append(destination)
|
||||
switch destination {
|
||||
case .invitation(let accessPolicy):
|
||||
return await repository.shareSources(
|
||||
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||
case .contact(let endpointId):
|
||||
return await repository.sendToContact(
|
||||
endpointId: endpointId, sources: [], transferName: transferName, senderName: senderName
|
||||
)
|
||||
guard case .invitation(let accessPolicy) = destination else {
|
||||
return .failure(TestError.unimplemented)
|
||||
}
|
||||
return await repository.shareSources(
|
||||
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ final class AppGraph: ObservableObject {
|
||||
let preferencesRepository: AppPreferencesRepository
|
||||
let filePreviewRepository: FilePreviewRepository
|
||||
let approvalCoordinator: ApprovalCoordinator
|
||||
let contactsModel: ContactsModel
|
||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||
let backgroundActivity: BackgroundActivityController
|
||||
|
||||
@@ -28,12 +27,6 @@ final class AppGraph: ObservableObject {
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
self.contactsModel = ContactsModel(
|
||||
repository: coreRepository,
|
||||
messages: messages,
|
||||
preferences: preferencesRepository,
|
||||
fileSystemService: dependencies.fileSystemService
|
||||
)
|
||||
self.approvalCoordinator = ApprovalCoordinator(
|
||||
repository: coreRepository,
|
||||
notifications: dependencies.notificationService,
|
||||
|
||||
@@ -60,11 +60,6 @@ struct RootView: View {
|
||||
approvals: graph.approvalCoordinator,
|
||||
sendModel: sendModel
|
||||
)
|
||||
ContactPromptLayer(
|
||||
contacts: graph.contactsModel,
|
||||
receiveModel: receiveModel,
|
||||
approvals: graph.approvalCoordinator
|
||||
)
|
||||
// Top-most so the toast is never covered by the approval overlay's
|
||||
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
||||
SnackbarHost(controller: graph.messages)
|
||||
@@ -93,9 +88,6 @@ struct RootView: View {
|
||||
// unfocused/occluded (common on macOS) live events may not have
|
||||
// rendered, leaving progress/status stale.
|
||||
Task { _ = await graph.coreRepository.refresh() }
|
||||
// Opt-in and foreground-only: collecting transfers held for this
|
||||
// device also tells every contact that the app was opened.
|
||||
Task { await graph.contactsModel.checkForOffersOnForeground() }
|
||||
case .background:
|
||||
graph.visibility.setForeground(false)
|
||||
// Hold the process open for iOS's grace window so an active
|
||||
@@ -173,10 +165,10 @@ struct RootView: View {
|
||||
@ViewBuilder
|
||||
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
|
||||
switch destination {
|
||||
case .send: SendScreen(model: sendModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
||||
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
||||
case .settings:
|
||||
SettingsScreen(model: settingsModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||
SettingsScreen(model: settingsModel, windowClass: windowClass)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,50 +291,3 @@ import AppKit
|
||||
/// belongs to a transfer this device is sending, and these belong to a device
|
||||
/// asking to reach it. Both are suppressed while the other is up so the user is
|
||||
/// never answering two modals at once.
|
||||
private struct ContactPromptLayer: View {
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let receiveModel: ReceiveModel
|
||||
@ObservedObject var approvals: ApprovalCoordinator
|
||||
|
||||
@State private var showPrompt = false
|
||||
|
||||
var body: some View {
|
||||
ContactPromptHost(
|
||||
isPresented: $showPrompt,
|
||||
state: contacts.state,
|
||||
onPairingResponse: { endpointId, accepted in
|
||||
Task { await contacts.respondToPairing(endpointId: endpointId, accepted: accepted) }
|
||||
},
|
||||
onOfferResponse: { offerId, accepted in
|
||||
Task {
|
||||
// The ticket is released only on acceptance; the receive then
|
||||
// runs through the ordinary path so the platform picks the
|
||||
// destination.
|
||||
if let ticket = await contacts.respondToOffer(offerId: offerId, accepted: accepted) {
|
||||
receiveModel.receiveOffered(ticket: ticket)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuggestionResponse: { suggestion, accepted in
|
||||
if accepted {
|
||||
Task { await contacts.acceptSuggestion(suggestion) }
|
||||
} else {
|
||||
contacts.declineSuggestion(suggestion)
|
||||
}
|
||||
}
|
||||
)
|
||||
.onChange(of: promptKey) { _, key in
|
||||
showPrompt = key != nil
|
||||
}
|
||||
}
|
||||
|
||||
/// One identity for "is there something to answer", so an offer replacing a
|
||||
/// pairing prompt re-presents rather than silently swapping content.
|
||||
private var promptKey: String? {
|
||||
guard approvals.state.current == nil else { return nil }
|
||||
if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" }
|
||||
if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" }
|
||||
if let suggestion = contacts.state.currentSuggestion { return "suggest-\(suggestion.endpointId)" }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ protocol FileSystemService {
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
destination: ShareDestination
|
||||
) async -> Result<ContactSendOutcome, Error>
|
||||
) async -> Result<Share, Error>
|
||||
}
|
||||
|
||||
extension FileSystemService {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// Consent prompts for device history, presented as sheets like the receiver
|
||||
/// approval modal.
|
||||
///
|
||||
/// Both are dismissable by answering only. An incoming offer in particular must
|
||||
/// not be acceptable by accident, and a swipe-away would leave the sender
|
||||
/// waiting on a decision that never comes.
|
||||
struct ContactPromptHost: View {
|
||||
/// Driven by the host so a prompt is never presented while another sheet is
|
||||
/// still animating out — macOS silently drops the second one.
|
||||
@Binding var isPresented: Bool
|
||||
let state: ContactsState
|
||||
let onPairingResponse: (String, Bool) -> Void
|
||||
let onOfferResponse: (String, Bool) -> Void
|
||||
let onSuggestionResponse: (PairingSuggestion, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.sheet(isPresented: $isPresented) {
|
||||
// Ordered by who is waiting: a sender is blocked on an offer, a
|
||||
// pairing request keeps until its consent window lapses, and a
|
||||
// post-transfer suggestion has nobody waiting at all.
|
||||
if let offer = state.currentOffer {
|
||||
OfferSheet(
|
||||
offer: offer,
|
||||
busy: state.busyOfferIds.contains(offer.offerId),
|
||||
onRespond: onOfferResponse
|
||||
)
|
||||
.interactiveDismissDisabled(true)
|
||||
.modifier(ContactPromptDetents())
|
||||
} else if let pairing = state.currentPairing {
|
||||
PairingSheet(
|
||||
pairing: pairing,
|
||||
busy: state.busyEndpoints.contains(pairing.endpointId),
|
||||
onRespond: onPairingResponse
|
||||
)
|
||||
.interactiveDismissDisabled(true)
|
||||
.modifier(ContactPromptDetents())
|
||||
} else if let suggestion = state.currentSuggestion {
|
||||
// Lowest priority: nobody is waiting on this answer, it just
|
||||
// follows a transfer that already finished.
|
||||
SuggestionSheet(
|
||||
suggestion: suggestion,
|
||||
busy: state.busyEndpoints.contains(suggestion.endpointId),
|
||||
onRespond: onSuggestionResponse
|
||||
)
|
||||
.interactiveDismissDisabled(true)
|
||||
.modifier(ContactPromptDetents())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactPromptDetents: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
content.presentationDetents([.medium])
|
||||
#else
|
||||
content.frame(minWidth: 420, minHeight: 300)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// "A remembered device wants to send you files."
|
||||
private struct OfferSheet: View {
|
||||
let offer: IncomingOfferModel
|
||||
let busy: Bool
|
||||
let onRespond: (String, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemSymbol: .trayAndArrowDownFill)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(String(localized: L10n.Offer.title))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(L10n.Offer.body(device: offer.resolvedSenderName, transferName: offer.transferName))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(L10n.Transfer.fileCount(count: Int(offer.fileCount)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .cancel) {
|
||||
onRespond(offer.offerId, false)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Offer.decline)).frame(maxWidth: .infinity)
|
||||
}
|
||||
Button {
|
||||
onRespond(offer.offerId, true)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Offer.accept)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
/// "This device offered to let you reach it. Remember it?"
|
||||
private struct PairingSheet: View {
|
||||
let pairing: PendingPairingModel
|
||||
let busy: Bool
|
||||
let onRespond: (String, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemSymbol: .macbookAndIphone)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(String(localized: L10n.Pairing.requestTitle))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(L10n.Pairing.requestBody(device: pairing.resolvedName))
|
||||
.multilineTextAlignment(.center)
|
||||
// Names are peer-supplied; the endpoint id is what actually identifies
|
||||
// the device.
|
||||
Text(L10n.Approval.endpointId(deviceId: pairing.endpointId))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .cancel) {
|
||||
onRespond(pairing.endpointId, false)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
|
||||
}
|
||||
Button {
|
||||
onRespond(pairing.endpointId, true)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.accept)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
/// "You just transferred with this device. Let it reach you next time?"
|
||||
private struct SuggestionSheet: View {
|
||||
let suggestion: PairingSuggestion
|
||||
let busy: Bool
|
||||
let onRespond: (PairingSuggestion, Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemSymbol: .clockArrowCirclepath)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(String(localized: L10n.Pairing.allowTitle))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(L10n.Pairing.requestBody(device: suggestion.resolvedName))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(String(localized: L10n.Pairing.allowBody))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 12) {
|
||||
Button(role: .cancel) {
|
||||
onRespond(suggestion, false)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
|
||||
}
|
||||
Button {
|
||||
onRespond(suggestion, true)
|
||||
} label: {
|
||||
Text(String(localized: L10n.Pairing.allowConfirm)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// A device worth remembering after a completed transfer.
|
||||
///
|
||||
/// Only a suggestion: nothing is issued until the user agrees, because being
|
||||
/// reachable is a standing permission and a transfer is a one-off.
|
||||
struct PairingSuggestion: Equatable, Identifiable {
|
||||
let endpointId: String
|
||||
let displayName: String?
|
||||
let transferName: String?
|
||||
|
||||
var id: String { endpointId }
|
||||
|
||||
var resolvedName: String {
|
||||
guard let displayName, !displayName.isEmpty else {
|
||||
return String(localized: L10n.Approval.nearbyDevice)
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsState: Equatable {
|
||||
var contacts: [DeviceContact] = []
|
||||
var blocked: [String] = []
|
||||
var pendingPairings: [PendingPairingModel] = []
|
||||
var pendingOffers: [IncomingOfferModel] = []
|
||||
var grantLifetime: GrantLifetimeOption = .days90
|
||||
var isLoading = false
|
||||
/// Endpoints with an in-flight decision, so a row can disable itself without
|
||||
/// blocking the rest of the list.
|
||||
var busyEndpoints: Set<String> = []
|
||||
var busyOfferIds: Set<String> = []
|
||||
var suggestions: [PairingSuggestion] = []
|
||||
/// Transfers this device is holding for contacts that were not running.
|
||||
var heldOffers: [HeldOfferModel] = []
|
||||
var checkForOffersOnOpen = false
|
||||
var isCheckingForOffers = false
|
||||
var selectedEndpointId: String?
|
||||
|
||||
var selected: DeviceContact? {
|
||||
guard let selectedEndpointId else { return nil }
|
||||
return contacts.first { $0.endpointId == selectedEndpointId }
|
||||
}
|
||||
|
||||
/// One prompt at a time: pairing consent is a modal decision and stacking
|
||||
/// sheets on top of each other reads as a loop of dialogs.
|
||||
var currentPairing: PendingPairingModel? { pendingPairings.first }
|
||||
var currentOffer: IncomingOfferModel? { pendingOffers.first }
|
||||
var currentSuggestion: PairingSuggestion? { suggestions.first }
|
||||
}
|
||||
|
||||
/// Drives the device-history surfaces: the list, its detail, and the two
|
||||
/// consent prompts. Ported in the MVVM shape used by the other feature models.
|
||||
@MainActor
|
||||
final class ContactsModel: ObservableObject {
|
||||
@Published private(set) var state = ContactsState()
|
||||
|
||||
/// Set when the detail screen asks for a file picker; the platform picker
|
||||
/// modifier observes it, mirroring `SendModel`.
|
||||
@Published var pendingFilePick = false
|
||||
/// Device the picked files are destined for.
|
||||
@Published private(set) var sendTarget: String?
|
||||
|
||||
private let repository: CoreGateway
|
||||
private let messages: UiMessageController
|
||||
private let preferences: AppPreferencesRepository
|
||||
private let fileSystemService: FileSystemService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
repository: CoreGateway,
|
||||
messages: UiMessageController,
|
||||
preferences: AppPreferencesRepository,
|
||||
fileSystemService: FileSystemService
|
||||
) {
|
||||
self.repository = repository
|
||||
self.messages = messages
|
||||
self.preferences = preferences
|
||||
self.fileSystemService = fileSystemService
|
||||
state.grantLifetime = preferences.preferences.grantLifetime
|
||||
state.checkForOffersOnOpen = preferences.preferences.checkForOffersOnOpen
|
||||
|
||||
repository.signals
|
||||
.sink { [weak self] signal in
|
||||
guard let self else { return }
|
||||
switch signal {
|
||||
case .contactsChanged:
|
||||
Task { await self.refresh() }
|
||||
case .offersChanged:
|
||||
Task { await self.refreshOffers() }
|
||||
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||
// A completed delivery names the device that received from us.
|
||||
Task { await self.considerSendPeers(transferId: transferId) }
|
||||
case .approvalChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.statePublisher
|
||||
.sink { [weak self] core in
|
||||
guard let self, core.isInitialized else { return }
|
||||
self.considerReceivePeers(core.transfers)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.statePublisher
|
||||
.map(\.isInitialized)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] isInitialized in
|
||||
guard let self, isInitialized else { return }
|
||||
// The core owns the lifetime; push the stored preference on start
|
||||
// so a restart does not silently fall back to the default.
|
||||
Task {
|
||||
await self.repository.setGrantLifetime(self.state.grantLifetime)
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
func refresh() async {
|
||||
state.isLoading = true
|
||||
defer { state.isLoading = false }
|
||||
|
||||
switch await repository.contacts() {
|
||||
case .success(let contacts):
|
||||
state.contacts = contacts
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
if case .success(let blocked) = await repository.blockedContacts() {
|
||||
state.blocked = blocked
|
||||
}
|
||||
if case .success(let held) = await repository.heldOffers() {
|
||||
state.heldOffers = held
|
||||
}
|
||||
state.pendingPairings = await repository.pendingPairings()
|
||||
await refreshOffers()
|
||||
}
|
||||
|
||||
func refreshOffers() async {
|
||||
state.pendingOffers = await repository.pendingOffers()
|
||||
}
|
||||
|
||||
// MARK: - Post-transfer suggestions
|
||||
|
||||
/// A completed receive names its sender, so that device becomes a candidate.
|
||||
private func considerReceivePeers(_ transfers: [Transfer]) {
|
||||
let candidates = transfers
|
||||
.filter { $0.direction == .receive && $0.status == .done }
|
||||
.compactMap { transfer -> PairingSuggestion? in
|
||||
guard let peerId = transfer.peerId else { return nil }
|
||||
return PairingSuggestion(
|
||||
endpointId: peerId,
|
||||
displayName: nil,
|
||||
transferName: transfer.transferName
|
||||
)
|
||||
}
|
||||
add(suggestions: candidates)
|
||||
}
|
||||
|
||||
/// A completed delivery names the device we sent to.
|
||||
private func considerSendPeers(transferId: UInt64) async {
|
||||
guard case .success(let requests) = await repository.receiverRequests(transferId: transferId) else {
|
||||
return
|
||||
}
|
||||
let candidates = requests
|
||||
.filter { $0.status == .completed }
|
||||
.map { request in
|
||||
PairingSuggestion(
|
||||
endpointId: request.remoteEndpointId,
|
||||
displayName: request.receiverName ?? request.receiverDeviceName,
|
||||
transferName: request.transferName
|
||||
)
|
||||
}
|
||||
add(suggestions: candidates)
|
||||
}
|
||||
|
||||
/// Filters candidates down to devices actually worth asking about.
|
||||
private func add(suggestions candidates: [PairingSuggestion]) {
|
||||
let known = Set(state.contacts.map(\.endpointId))
|
||||
let blocked = Set(state.blocked)
|
||||
let declined = preferences.preferences.declinedPairingSuggestions
|
||||
let pending = Set(state.suggestions.map(\.endpointId))
|
||||
|
||||
let fresh = candidates.filter { candidate in
|
||||
!known.contains(candidate.endpointId)
|
||||
&& !blocked.contains(candidate.endpointId)
|
||||
&& !declined.contains(candidate.endpointId)
|
||||
&& !pending.contains(candidate.endpointId)
|
||||
}
|
||||
guard !fresh.isEmpty else { return }
|
||||
state.suggestions.append(contentsOf: fresh)
|
||||
}
|
||||
|
||||
/// Agree to be reachable by a suggested device.
|
||||
func acceptSuggestion(_ suggestion: PairingSuggestion) async {
|
||||
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
|
||||
preferences.clearDeclinedPairingSuggestion(suggestion.endpointId)
|
||||
await allowDeviceToReachMe(
|
||||
endpointId: suggestion.endpointId,
|
||||
displayName: preferences.preferences.username
|
||||
)
|
||||
}
|
||||
|
||||
/// Decline, and remember the decline so the next transfer does not re-ask.
|
||||
func declineSuggestion(_ suggestion: PairingSuggestion) {
|
||||
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
|
||||
preferences.declinePairingSuggestion(suggestion.endpointId)
|
||||
}
|
||||
|
||||
// MARK: - Collecting waiting transfers
|
||||
|
||||
func setCheckForOffersOnOpen(_ enabled: Bool) {
|
||||
state.checkForOffersOnOpen = enabled
|
||||
preferences.setCheckForOffersOnOpen(enabled)
|
||||
}
|
||||
|
||||
/// Called when the app comes to the foreground.
|
||||
///
|
||||
/// Opt-in, because asking every contact whether they have something waiting
|
||||
/// also tells them the app was opened. Never runs in the background.
|
||||
func checkForOffersOnForeground() async {
|
||||
guard state.checkForOffersOnOpen else { return }
|
||||
_ = await collectWaitingOffers()
|
||||
}
|
||||
|
||||
/// Explicit "check now". Returns how many transfers were collected so the
|
||||
/// caller can report an empty result, which a silent refresh cannot.
|
||||
@discardableResult
|
||||
func collectWaitingOffers() async -> UInt64 {
|
||||
guard !state.isCheckingForOffers else { return 0 }
|
||||
state.isCheckingForOffers = true
|
||||
defer { state.isCheckingForOffers = false }
|
||||
|
||||
switch await repository.pollContactsForOffers() {
|
||||
case .success(let collected):
|
||||
await refreshOffers()
|
||||
return collected
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }
|
||||
|
||||
// MARK: - Pairing consent
|
||||
|
||||
/// Agree to be reachable by a device, typically right after a transfer.
|
||||
func allowDeviceToReachMe(endpointId: String, displayName: String?) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
if case .failure(let error) = await repository.allowDeviceToReachMe(
|
||||
endpointId: endpointId,
|
||||
displayName: displayName
|
||||
) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
/// Answer a device's offer to be remembered.
|
||||
func respondToPairing(endpointId: String, accepted: Bool) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
switch await repository.respondToPairing(endpointId: endpointId, accepted: accepted) {
|
||||
case .success:
|
||||
// Drop the prompt immediately: the core has already consumed it, and
|
||||
// leaving it on screen invites a second answer that does nothing.
|
||||
state.pendingPairings.removeAll { $0.endpointId == endpointId }
|
||||
if accepted { await refresh() }
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Incoming offers
|
||||
|
||||
/// Answer an incoming offer. Returns the ticket when accepted so the caller
|
||||
/// can run the receive with a platform-appropriate destination; the core
|
||||
/// releases it only on acceptance.
|
||||
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||
state.busyOfferIds.insert(offerId)
|
||||
defer { state.busyOfferIds.remove(offerId) }
|
||||
|
||||
let ticket = await repository.respondToOffer(offerId: offerId, accepted: accepted)
|
||||
state.pendingOffers.removeAll { $0.offerId == offerId }
|
||||
return ticket
|
||||
}
|
||||
|
||||
// MARK: - Sending to a device
|
||||
|
||||
/// Start choosing files to send to a remembered device.
|
||||
func chooseFilesToSend(to endpointId: String) {
|
||||
sendTarget = endpointId
|
||||
pendingFilePick = true
|
||||
}
|
||||
|
||||
func onFilePickFailed(_ reason: String) {
|
||||
sendTarget = nil
|
||||
messages.error(InvitationError.raw(reason))
|
||||
}
|
||||
|
||||
/// Send the picked selection straight to the chosen device.
|
||||
///
|
||||
/// Only the receiving user is prompted; this call returns once they have
|
||||
/// answered, so the button stays busy until then.
|
||||
func onFilesPicked(_ files: [PickedShareFile]) async {
|
||||
guard let endpointId = sendTarget else { return }
|
||||
sendTarget = nil
|
||||
guard !files.isEmpty else { return }
|
||||
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
let result = await fileSystemService.sharePickedFiles(
|
||||
repository: repository,
|
||||
files: files,
|
||||
transferName: files.count == 1 ? files[0].displayName : "",
|
||||
senderName: preferences.preferences.username,
|
||||
destination: .contact(endpointId: endpointId)
|
||||
)
|
||||
await fileSystemService.discardPickedFiles(files)
|
||||
switch result {
|
||||
case .success(let outcome):
|
||||
// A closed app is a delay, not a failure: say so rather than
|
||||
// reporting success for something nobody has received.
|
||||
let text: UiText = outcome.delivered
|
||||
? .resource(L10n.Send.transferCreated)
|
||||
: .resource(L10n.Contacts.offerHeld)
|
||||
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
|
||||
await refresh()
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire-and-report variant of ``offerTransfer(transferId:to:)``.
|
||||
///
|
||||
/// Owned by the model rather than a view so the request survives the picker
|
||||
/// being dismissed: the answer depends on a person at the other device.
|
||||
func offerTransferInBackground(transferId: UInt64, to contact: DeviceContact) {
|
||||
Task { await offerTransfer(transferId: transferId, to: contact) }
|
||||
}
|
||||
|
||||
/// Push an existing transfer to a remembered device.
|
||||
///
|
||||
/// Returns whether it landed, so the caller can distinguish "accepted" from
|
||||
/// "waiting for that device to open the app".
|
||||
@discardableResult
|
||||
func offerTransfer(transferId: UInt64, to contact: DeviceContact) async -> Bool {
|
||||
state.busyEndpoints.insert(contact.endpointId)
|
||||
defer { state.busyEndpoints.remove(contact.endpointId) }
|
||||
|
||||
switch await repository.offerTransferToContact(
|
||||
transferId: transferId,
|
||||
endpointId: contact.endpointId
|
||||
) {
|
||||
case .success(let outcome):
|
||||
let text: UiText = outcome.delivered
|
||||
? .dynamic(L10n.Contacts.sentToDevice(device: contact.displayName))
|
||||
: .resource(L10n.Contacts.offerHeld)
|
||||
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
|
||||
await refresh()
|
||||
return outcome.delivered
|
||||
case .failure(let error) where error.offerRefusal != nil:
|
||||
// The offer was delivered and a person said no, or nobody answered.
|
||||
// Neither is a failure of this device, so neither is shown as one.
|
||||
let text = error.offerRefusal == .declined
|
||||
? L10n.Contacts.declinedByDevice(device: contact.displayName)
|
||||
: L10n.Contacts.noAnswer(device: contact.displayName)
|
||||
messages.tryShow(UiMessage(text: .dynamic(text), tone: .info))
|
||||
await refresh()
|
||||
return false
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Management
|
||||
|
||||
func setLabel(endpointId: String, label: String) async {
|
||||
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if case .failure(let error) = await repository.setContactLabel(
|
||||
endpointId: endpointId,
|
||||
label: trimmed.isEmpty ? nil : trimmed
|
||||
) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func forget(endpointId: String) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
if case .failure(let error) = await repository.forgetContact(endpointId: endpointId) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func forgetAll() async {
|
||||
if case .failure(let error) = await repository.forgetAllContacts() {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
state.selectedEndpointId = nil
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func block(endpointId: String) async {
|
||||
state.busyEndpoints.insert(endpointId)
|
||||
defer { state.busyEndpoints.remove(endpointId) }
|
||||
|
||||
if case .failure(let error) = await repository.blockContact(endpointId: endpointId) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func unblock(endpointId: String) async {
|
||||
if case .failure(let error) = await repository.unblockContact(endpointId: endpointId) {
|
||||
messages.error(error)
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
|
||||
state.grantLifetime = lifetime
|
||||
preferences.setGrantLifetime(lifetime)
|
||||
Task { await repository.setGrantLifetime(lifetime) }
|
||||
}
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// Device history: the remembered devices, their detail, and the block list.
|
||||
///
|
||||
/// Pushed from Settings rather than owning a tab — it is a management surface,
|
||||
/// not part of the send/receive flow.
|
||||
struct ContactsScreen: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
/// Reports an empty result, which a silent refresh cannot convey.
|
||||
let onNothingWaiting: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text(String(localized: L10n.Contacts.subtitle))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if model.state.contacts.isEmpty {
|
||||
Section {
|
||||
ContactsEmptyState()
|
||||
}
|
||||
} else {
|
||||
Section(String(localized: L10n.Contacts.title)) {
|
||||
ForEach(model.state.contacts) { contact in
|
||||
NavigationLink(value: SettingsSection.contactDetail(endpointId: contact.endpointId)) {
|
||||
ContactRow(contact: contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !model.state.heldOffers.isEmpty {
|
||||
Section(String(localized: L10n.Contacts.waitingTitle)) {
|
||||
ForEach(model.state.heldOffers) { offer in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(offer.transferName)
|
||||
Text(String(offer.endpointId.prefix(16)))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
Text(String(localized: L10n.Contacts.waitingHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !model.state.blocked.isEmpty {
|
||||
Section(String(localized: L10n.Contacts.blockedTitle)) {
|
||||
ForEach(model.state.blocked, id: \.self) { endpointId in
|
||||
BlockedRow(endpointId: endpointId) {
|
||||
Task { await model.unblock(endpointId: endpointId) }
|
||||
}
|
||||
}
|
||||
Text(String(localized: L10n.Contacts.unblockHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
CollectOffersSection(model: model, onNothingWaiting: onNothingWaiting)
|
||||
|
||||
GrantLifetimeSection(model: model)
|
||||
|
||||
if !model.state.contacts.isEmpty {
|
||||
Section {
|
||||
ForgetAllButton { Task { await model.forgetAll() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(String(localized: L10n.Contacts.title)))
|
||||
.task { await model.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactsEmptyState: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemSymbol: .macbookAndIphone)
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.tint)
|
||||
Text(String(localized: L10n.Contacts.emptyTitle))
|
||||
.font(.headline)
|
||||
Text(String(localized: L10n.Contacts.emptyBody))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactRow: View {
|
||||
let contact: DeviceContact
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(contact.displayName)
|
||||
if contact.canSend {
|
||||
if let lastTransferAt = contact.lastTransferAt {
|
||||
Text(L10n.Contacts.lastTransfer(date: Self.format(lastTransferAt)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
// Reachability is derived from holding a live grant, so this is
|
||||
// the honest signal that sending will not work.
|
||||
Label(
|
||||
String(localized: L10n.Contacts.unreachable),
|
||||
systemSymbol: .exclamationmarkTriangleFill
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func format(_ millis: Int64) -> String {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(millis) / 1_000)
|
||||
return date.formatted(.relative(presentation: .named))
|
||||
}
|
||||
}
|
||||
|
||||
private struct BlockedRow: View {
|
||||
let endpointId: String
|
||||
let onUnblock: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(String(endpointId.prefix(16)))
|
||||
.font(.callout.monospaced())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button(String(localized: L10n.Contacts.unblock), action: onUnblock)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CollectOffersSection: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
let onNothingWaiting: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: L10n.Contacts.checkOnOpen),
|
||||
isOn: Binding(
|
||||
get: { model.state.checkForOffersOnOpen },
|
||||
set: { model.setCheckForOffersOnOpen($0) }
|
||||
)
|
||||
)
|
||||
Button {
|
||||
Task {
|
||||
let collected = await model.collectWaitingOffers()
|
||||
if collected == 0 { onNothingWaiting() }
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(String(localized: L10n.Contacts.checkNow))
|
||||
if model.state.isCheckingForOffers {
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(model.state.isCheckingForOffers)
|
||||
} footer: {
|
||||
// The privacy cost is the point of the setting, so it is stated
|
||||
// where the switch is, not buried elsewhere.
|
||||
Text(String(localized: L10n.Contacts.checkOnOpenHint))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GrantLifetimeSection: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker(
|
||||
String(localized: L10n.Contacts.grantLifetimeTitle),
|
||||
selection: Binding(
|
||||
get: { model.state.grantLifetime },
|
||||
set: { model.setGrantLifetime($0) }
|
||||
)
|
||||
) {
|
||||
ForEach(GrantLifetimeOption.allCases) { option in
|
||||
Text(Self.label(option)).tag(option)
|
||||
}
|
||||
}
|
||||
Text(String(localized: L10n.Contacts.grantLifetimeHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private static func label(_ option: GrantLifetimeOption) -> String {
|
||||
guard let days = option.days else {
|
||||
return String(localized: L10n.Contacts.grantLifetimeNever)
|
||||
}
|
||||
return L10n.Contacts.grantLifetimeDays(count: days)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ForgetAllButton: View {
|
||||
let onConfirm: () -> Void
|
||||
@State private var isConfirming = false
|
||||
|
||||
var body: some View {
|
||||
Button(role: .destructive) {
|
||||
isConfirming = true
|
||||
} label: {
|
||||
Text(String(localized: L10n.Contacts.forgetAll))
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: L10n.Contacts.forgetAll),
|
||||
isPresented: $isConfirming,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: L10n.Contacts.forgetAll), role: .destructive, action: onConfirm)
|
||||
} message: {
|
||||
Text(String(localized: L10n.Contacts.forgetBody))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detail for one remembered device: rename, send, forget, block.
|
||||
struct ContactDetailScreen: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
let endpointId: String
|
||||
|
||||
@State private var label = ""
|
||||
@State private var isConfirmingForget = false
|
||||
@State private var isConfirmingBlock = false
|
||||
|
||||
private var contact: DeviceContact? {
|
||||
model.state.contacts.first { $0.endpointId == endpointId }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if let contact {
|
||||
Section {
|
||||
TextField(
|
||||
String(localized: L10n.Contacts.nameField),
|
||||
text: $label,
|
||||
prompt: Text(contact.displayName)
|
||||
)
|
||||
.onSubmit { commitLabel() }
|
||||
Text(String(localized: L10n.Contacts.nameHint))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
// The endpoint id is the only real identity: two devices can
|
||||
// claim the same name, but not the same key. Shown in full
|
||||
// and selectable so it can actually be compared.
|
||||
Text(L10n.Approval.endpointId(deviceId: contact.endpointId))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if contact.canSend {
|
||||
Section {
|
||||
Button {
|
||||
model.chooseFilesToSend(to: endpointId)
|
||||
} label: {
|
||||
Label(
|
||||
String(localized: L10n.Contacts.sendTo),
|
||||
systemSymbol: .paperplane
|
||||
)
|
||||
}
|
||||
.disabled(model.state.busyEndpoints.contains(endpointId))
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Label(
|
||||
String(localized: L10n.Contacts.unreachableBody),
|
||||
systemSymbol: .exclamationmarkTriangleFill
|
||||
)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
isConfirmingForget = true
|
||||
} label: {
|
||||
Text(String(localized: L10n.Contacts.forget))
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
isConfirmingBlock = true
|
||||
} label: {
|
||||
Text(String(localized: L10n.Contacts.block))
|
||||
}
|
||||
}
|
||||
.disabled(model.state.busyEndpoints.contains(endpointId))
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(contact?.displayName ?? ""))
|
||||
.contactSendPickers(model: model)
|
||||
.onAppear { label = contact?.localLabel ?? "" }
|
||||
.onDisappear { commitLabel() }
|
||||
.confirmationDialog(
|
||||
String(localized: L10n.Contacts.forget),
|
||||
isPresented: $isConfirmingForget,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: L10n.Contacts.forget), role: .destructive) {
|
||||
Task { await model.forget(endpointId: endpointId) }
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: L10n.Contacts.forgetBody))
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: L10n.Contacts.block),
|
||||
isPresented: $isConfirmingBlock,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: L10n.Contacts.block), role: .destructive) {
|
||||
Task { await model.block(endpointId: endpointId) }
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: L10n.Contacts.unblockHint))
|
||||
}
|
||||
}
|
||||
|
||||
private func commitLabel() {
|
||||
guard label != (contact?.localLabel ?? "") else { return }
|
||||
Task { await model.setLabel(endpointId: endpointId, label: label) }
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// Picks a remembered device to send an existing transfer to.
|
||||
///
|
||||
/// Offered next to the QR code as another way to deliver the same invitation,
|
||||
/// not as a second share of the same files.
|
||||
struct DevicePickerSheet: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
let transferId: UInt64
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Only devices holding a live grant: the rest cannot be reached until they
|
||||
/// are paired again, so offering them here would fail on tap.
|
||||
private var reachable: [DeviceContact] {
|
||||
model.state.contacts.filter(\.canSend)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if reachable.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label(
|
||||
String(localized: L10n.Contacts.pickDeviceTitle),
|
||||
systemSymbol: .macbookAndIphone
|
||||
)
|
||||
} description: {
|
||||
Text(String(localized: L10n.Contacts.pickDeviceEmpty))
|
||||
}
|
||||
} else {
|
||||
List(reachable) { contact in
|
||||
Button {
|
||||
// Close first. The other device's user has to accept,
|
||||
// which can take as long as they take, and holding a
|
||||
// modal open on someone else's decision reads as a
|
||||
// hang. The outcome arrives as a message instead.
|
||||
dismiss()
|
||||
model.offerTransferInBackground(transferId: transferId, to: contact)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(contact.displayName)
|
||||
Text(contact.shortFingerprint)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if model.state.busyEndpoints.contains(contact.endpointId) {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(model.state.busyEndpoints.contains(contact.endpointId))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(String(localized: L10n.Contacts.pickDeviceTitle)))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: L10n.Button.cancel)) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await model.refresh() }
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 380, minHeight: 320)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ final class TransferNotificationCoordinator: ObservableObject {
|
||||
switch signal {
|
||||
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||
Task { await self.syncReceivers(transferId: transferId) }
|
||||
case .approvalChanged, .contactsChanged, .offersChanged:
|
||||
case .approvalChanged, .transfersChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,6 @@ final class SendModel: ObservableObject {
|
||||
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
||||
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
||||
self.refreshReceiverStatuses(for: id)
|
||||
case .contactsChanged, .offersChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -5,7 +5,6 @@ import SFSafeSymbols
|
||||
/// with the composer and detail panels as native sheets and delete as an alert.
|
||||
struct SendScreen: View {
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let windowClass: WindowClass
|
||||
|
||||
/// Transfer pending an inline (list-level) delete confirmation.
|
||||
@@ -61,7 +60,7 @@ struct SendScreen: View {
|
||||
onDismissed: model.shareSheetDidDismiss
|
||||
) {
|
||||
if let shareTarget {
|
||||
TransferSharePanel(model: model, contacts: contacts, transfer: shareTarget)
|
||||
TransferSharePanel(model: model, transfer: shareTarget)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +93,7 @@ struct SendScreen: View {
|
||||
/// alert attached here so they present from the detail's own context (presenting
|
||||
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
||||
private func detailView(for transfer: Transfer) -> some View {
|
||||
TransferDetailsView(model: model, contacts: contacts, transfer: transfer, events: model.coreState.events)
|
||||
TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events)
|
||||
.adaptiveDrawer(
|
||||
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||
windowClass: windowClass,
|
||||
@@ -102,7 +101,7 @@ struct SendScreen: View {
|
||||
onDismissed: model.shareSheetDidDismiss
|
||||
) {
|
||||
if let panel = model.state.detailPanel {
|
||||
DetailPanelContent(model: model, contacts: contacts, transfer: transfer, panel: panel)
|
||||
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
|
||||
@@ -6,7 +6,6 @@ import CoreImage.CIFilterBuiltins
|
||||
|
||||
struct TransferDetailsView: View {
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
let events: [CoreEventModel]
|
||||
@State private var showStopConfirmation = false
|
||||
@@ -130,7 +129,6 @@ private struct DetailDestination: View {
|
||||
|
||||
struct DetailPanelContent: View {
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
let panel: TransferDetailPanel
|
||||
|
||||
@@ -148,7 +146,7 @@ struct DetailPanelContent: View {
|
||||
onAccept: model.acceptReceiver
|
||||
)
|
||||
case .share:
|
||||
TransferSharePanel(model: model, contacts: contacts, transfer: transfer)
|
||||
TransferSharePanel(model: model, transfer: transfer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,7 +296,6 @@ private struct ReceiverRow: View {
|
||||
struct TransferSharePanel: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
|
||||
var body: some View {
|
||||
@@ -312,7 +309,7 @@ struct TransferSharePanel: View {
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ShareActionsView(model: model, contacts: contacts, transfer: transfer, ticket: ticket)
|
||||
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
||||
case .preparing:
|
||||
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
||||
case .unavailable:
|
||||
|
||||
@@ -20,25 +20,14 @@ protocol TransferShareActions: AnyObject {
|
||||
struct ShareActionsView: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
@ObservedObject var model: SendModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let transfer: Transfer
|
||||
let ticket: String
|
||||
|
||||
@State private var actions: TransferShareActions = makePlatformShareActions()
|
||||
@State private var writingNfc = false
|
||||
@State private var choosingDevice = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Sending straight to a remembered device is another way to deliver
|
||||
// this same invitation, so it belongs with the other delivery
|
||||
// methods rather than in a separate flow.
|
||||
if contacts.state.contacts.contains(where: \.canSend) {
|
||||
SecondaryButton(
|
||||
title: String(localized: L10n.Contacts.sendToDevice),
|
||||
action: { choosingDevice = true }
|
||||
)
|
||||
}
|
||||
if actions.nfcAvailability != .hidden {
|
||||
SecondaryButton(
|
||||
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
|
||||
@@ -68,8 +57,5 @@ struct ShareActionsView: View {
|
||||
}, enabled: actions.canUseNativeShare)
|
||||
}
|
||||
.onDisappear { actions.cancelNfcWrite() }
|
||||
.sheet(isPresented: $choosingDevice) {
|
||||
DevicePickerSheet(model: contacts, transferId: transfer.transferId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@ enum SettingsSection: Hashable {
|
||||
case appearance
|
||||
case notifications
|
||||
case network
|
||||
case contacts
|
||||
/// One device's detail. Part of this enum because the Settings stack has a
|
||||
/// typed path: a link carrying any other value type cannot push onto it.
|
||||
case contactDetail(endpointId: String)
|
||||
case storage
|
||||
case about
|
||||
case bugReport
|
||||
@@ -23,7 +19,6 @@ enum SettingsSection: Hashable {
|
||||
case .appearance: return L10n.Appearance.title
|
||||
case .notifications: return L10n.Notifications.title
|
||||
case .network: return L10n.Settings.networkTitle
|
||||
case .contacts, .contactDetail: return L10n.Contacts.title
|
||||
case .storage: return L10n.Storage.title
|
||||
case .about: return L10n.About.title
|
||||
case .bugReport: return L10n.About.bugReport
|
||||
@@ -179,11 +174,6 @@ final class SettingsModel: ObservableObject {
|
||||
loadDeviceInfo()
|
||||
}
|
||||
|
||||
/// Surfaces "nothing waiting" from the contacts screen, which has no
|
||||
/// message controller of its own.
|
||||
func reportNothingWaiting() {
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Contacts.checkNone), tone: .info))
|
||||
}
|
||||
|
||||
func selectSection(_ section: SettingsSection) {
|
||||
state.selectedSection = section
|
||||
|
||||
@@ -5,7 +5,6 @@ import SFSafeSymbols
|
||||
/// navigation. The model stays the source of truth via a derived path binding.
|
||||
struct SettingsScreen: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
@ObservedObject var contacts: ContactsModel
|
||||
let windowClass: WindowClass
|
||||
@State private var showBugReport = false
|
||||
|
||||
@@ -15,8 +14,6 @@ struct SettingsScreen: View {
|
||||
switch model.state.selectedSection {
|
||||
case .overview: return []
|
||||
case .bugReport: return [.about, .bugReport]
|
||||
case .contactDetail(let endpointId):
|
||||
return [.contacts, .contactDetail(endpointId: endpointId)]
|
||||
case let section: return [section]
|
||||
}
|
||||
},
|
||||
@@ -57,15 +54,6 @@ struct SettingsScreen: View {
|
||||
NavigationLink(value: SettingsSection.storage) {
|
||||
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
||||
}
|
||||
NavigationLink(value: SettingsSection.contacts) {
|
||||
SettingsRow(
|
||||
icon: .macbookAndIphone,
|
||||
title: String(localized: L10n.Contacts.title),
|
||||
value: contacts.state.contacts.isEmpty
|
||||
? nil
|
||||
: String(contacts.state.contacts.count)
|
||||
)
|
||||
}
|
||||
}
|
||||
Section(String(localized: L10n.Settings.advancedTitle)) {
|
||||
NavigationLink(value: SettingsSection.network) {
|
||||
@@ -92,17 +80,7 @@ struct SettingsScreen: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionForm(_ section: SettingsSection) -> some View {
|
||||
// Contacts brings its own Form and push destination, so it is not wrapped
|
||||
// in the shared section chrome.
|
||||
if case .contactDetail(let endpointId) = section {
|
||||
ContactDetailScreen(model: contacts, endpointId: endpointId)
|
||||
} else if section == .contacts {
|
||||
ContactsScreen(model: contacts) {
|
||||
model.reportNothingWaiting()
|
||||
}
|
||||
} else {
|
||||
settingsSectionForm(section)
|
||||
}
|
||||
settingsSectionForm(section)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -157,9 +135,6 @@ private struct SettingsSectionContent: View {
|
||||
NetworkSettings(model: model)
|
||||
case .storage:
|
||||
StorageSettings(model: model)
|
||||
case .contacts, .contactDetail:
|
||||
// Rendered by SettingsScreen itself, which owns the contacts model.
|
||||
EmptyView()
|
||||
case .about:
|
||||
AboutSettings(model: model)
|
||||
case .bugReport:
|
||||
|
||||
@@ -60,23 +60,17 @@ struct IosFileSystemService: FileSystemService {
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
destination: ShareDestination
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
) async -> Result<Share, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
let sources = files.map { $0.toIosShareSource() }
|
||||
switch destination {
|
||||
case .invitation(let accessPolicy):
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||
case .contact(let endpointId):
|
||||
return await repository.sendToContact(
|
||||
endpointId: endpointId, sources: sources,
|
||||
transferName: transferName, senderName: senderName
|
||||
)
|
||||
guard case .invitation(let accessPolicy) = destination else {
|
||||
return .failure(InvitationError.unsupportedOperation)
|
||||
}
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
}
|
||||
|
||||
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
|
||||
|
||||
@@ -40,7 +40,7 @@ struct MacFileSystemService: FileSystemService {
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
destination: ShareDestination
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
) async -> Result<Share, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
@@ -63,18 +63,12 @@ struct MacFileSystemService: FileSystemService {
|
||||
let sources = files.map {
|
||||
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
||||
}
|
||||
switch destination {
|
||||
case .invitation(let accessPolicy):
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||
case .contact(let endpointId):
|
||||
return await repository.sendToContact(
|
||||
endpointId: endpointId, sources: sources,
|
||||
transferName: transferName, senderName: senderName
|
||||
)
|
||||
guard case .invitation(let accessPolicy) = destination else {
|
||||
return .failure(InvitationError.unsupportedOperation)
|
||||
}
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -72,30 +72,6 @@ struct SendPickers: ViewModifier {
|
||||
|
||||
/// File picker for "send to this device", reusing the share picker's selection
|
||||
/// handling so security-scoped bookmarks are captured the same way.
|
||||
struct ContactSendPickers: ViewModifier {
|
||||
@ObservedObject var model: ContactsModel
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.fileImporter(
|
||||
isPresented: $model.pendingFilePick,
|
||||
allowedContentTypes: [.item],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: false) }
|
||||
if files.isEmpty {
|
||||
model.onFilePickFailed("The selected document could not be opened")
|
||||
} else {
|
||||
Task { await model.onFilesPicked(files) }
|
||||
}
|
||||
case .failure(let error):
|
||||
if !error.isUserCancellation { model.onFilePickFailed(error.technicalDetail) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PickerSupport {
|
||||
static func receiveFolder(from url: URL) -> ReceiveFolder {
|
||||
@@ -157,7 +133,4 @@ extension View {
|
||||
modifier(SendPickers(model: model))
|
||||
}
|
||||
|
||||
func contactSendPickers(model: ContactsModel) -> some View {
|
||||
modifier(ContactSendPickers(model: model))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user