mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
feat(apple): collect transfers held for this device
Adds the opt-in foreground check and an explicit Check now, the waiting-to- be-delivered list on the sender side, and honest reporting when a send could not be delivered: a closed app is a delay, not a success nobody received. The setting is off by default and its footer states that checking reveals app-open times to remembered devices, since that is the reason it is a setting at all. Records in the design doc that this shipped as one global toggle rather than the per-contact opt-in originally specified.
This commit is contained in:
@@ -235,7 +235,13 @@ final class ContactsModelTests: XCTestCase {
|
||||
fileSystemService: files
|
||||
)
|
||||
gateway.sendToContactResult = .success(
|
||||
Share(transferId: 1, ticket: "vnd1:x", transferName: "doc", contentHash: "h", fileCount: 1, totalSize: 2)
|
||||
ContactSendOutcome(
|
||||
share: Share(
|
||||
transferId: 1, ticket: "vnd1:x", transferName: "doc",
|
||||
contentHash: "h", fileCount: 1, totalSize: 2
|
||||
),
|
||||
delivered: true
|
||||
)
|
||||
)
|
||||
|
||||
model.chooseFilesToSend(to: "peer")
|
||||
@@ -276,6 +282,98 @@ final class ContactsModelTests: XCTestCase {
|
||||
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"])
|
||||
}
|
||||
|
||||
func testUnreachableContactIsSurfacedForRepairing() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
||||
|
||||
@@ -90,7 +90,10 @@ final class FakeCoreGateway: CoreGateway {
|
||||
var respondToPairingResult: Result<Bool, Error> = .success(true)
|
||||
/// Ticket handed back when an offer is accepted; nil models a declined one.
|
||||
var offerTicket: String? = "vnd1:offered"
|
||||
var sendToContactResult: Result<Share, Error> = .failure(TestError.unimplemented)
|
||||
var 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([])
|
||||
|
||||
@@ -129,10 +132,15 @@ final class FakeCoreGateway: CoreGateway {
|
||||
sources: [ShareSource],
|
||||
transferName: String,
|
||||
senderName: String
|
||||
) async -> Result<Share, Error> {
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
sentToContacts.append(endpointId)
|
||||
return sendToContactResult
|
||||
}
|
||||
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
|
||||
@@ -168,13 +176,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<Share, Error> {
|
||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<ContactSendOutcome, 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
|
||||
|
||||
@@ -93,6 +93,9 @@ 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
|
||||
|
||||
@@ -127,6 +127,9 @@ struct AppPreferences: Equatable {
|
||||
/// 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 {
|
||||
@@ -152,6 +155,7 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
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) {
|
||||
@@ -175,7 +179,8 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
diagnosticsInstallId: installId,
|
||||
relayConfiguration: resolveRelayConfiguration(defaults),
|
||||
grantLifetime: grantLifetime,
|
||||
declinedPairingSuggestions: declined
|
||||
declinedPairingSuggestions: declined,
|
||||
checkForOffersOnOpen: defaults.bool(forKey: Key.checkForOffersOnOpen)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -237,6 +242,11 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
reload()
|
||||
}
|
||||
|
||||
func setCheckForOffersOnOpen(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.checkForOffersOnOpen)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
|
||||
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
|
||||
reload()
|
||||
|
||||
@@ -65,7 +65,14 @@ protocol CoreGateway: AnyObject {
|
||||
sources: [ShareSource],
|
||||
transferName: String,
|
||||
senderName: String
|
||||
) async -> Result<Share, Error>
|
||||
) 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>
|
||||
|
||||
@@ -269,6 +269,27 @@ struct IncomingOfferModel: Equatable, Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -335,7 +335,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
sources: [ShareSource],
|
||||
transferName: String,
|
||||
senderName: String
|
||||
) async -> Result<Share, Error> {
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
@@ -355,10 +355,18 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
accessMode: .approvalRequired
|
||||
)
|
||||
)
|
||||
return result.toModel()
|
||||
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) }
|
||||
}
|
||||
@@ -657,6 +665,20 @@ extension IncomingOffer {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -42,7 +42,7 @@ protocol FileSystemService {
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
destination: ShareDestination
|
||||
) async -> Result<Share, Error>
|
||||
) async -> Result<ContactSendOutcome, Error>
|
||||
}
|
||||
|
||||
extension FileSystemService {
|
||||
|
||||
@@ -32,6 +32,10 @@ struct ContactsState: Equatable {
|
||||
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? {
|
||||
@@ -75,6 +79,7 @@ final class ContactsModel: ObservableObject {
|
||||
self.preferences = preferences
|
||||
self.fileSystemService = fileSystemService
|
||||
state.grantLifetime = preferences.preferences.grantLifetime
|
||||
state.checkForOffersOnOpen = preferences.preferences.checkForOffersOnOpen
|
||||
|
||||
repository.signals
|
||||
.sink { [weak self] signal in
|
||||
@@ -130,6 +135,9 @@ final class ContactsModel: ObservableObject {
|
||||
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()
|
||||
}
|
||||
@@ -205,6 +213,40 @@ final class ContactsModel: ObservableObject {
|
||||
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 }
|
||||
@@ -290,8 +332,13 @@ final class ContactsModel: ObservableObject {
|
||||
)
|
||||
await fileSystemService.discardPickedFiles(files)
|
||||
switch result {
|
||||
case .success:
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success))
|
||||
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)
|
||||
|
||||
@@ -7,6 +7,8 @@ import SwiftUI
|
||||
/// 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 {
|
||||
@@ -30,6 +32,24 @@ struct ContactsScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -43,6 +63,18 @@ struct ContactsScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
CollectOffersSection(
|
||||
enabled: model.state.checkForOffersOnOpen,
|
||||
isChecking: model.state.isCheckingForOffers,
|
||||
onToggle: model.setCheckForOffersOnOpen,
|
||||
onCheckNow: {
|
||||
Task {
|
||||
let collected = await model.collectWaitingOffers()
|
||||
if collected == 0 { onNothingWaiting() }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
GrantLifetimeSection(
|
||||
selection: model.state.grantLifetime,
|
||||
onSelect: model.setGrantLifetime
|
||||
@@ -129,6 +161,36 @@ private struct BlockedRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct CollectOffersSection: View {
|
||||
let enabled: Bool
|
||||
let isChecking: Bool
|
||||
let onToggle: (Bool) -> Void
|
||||
let onCheckNow: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: L10n.Contacts.checkOnOpen),
|
||||
isOn: Binding(get: { enabled }, set: onToggle)
|
||||
)
|
||||
Button(action: onCheckNow) {
|
||||
HStack {
|
||||
Text(String(localized: L10n.Contacts.checkNow))
|
||||
if isChecking {
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isChecking)
|
||||
} 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 {
|
||||
let selection: GrantLifetimeOption
|
||||
let onSelect: (GrantLifetimeOption) -> Void
|
||||
|
||||
@@ -355,7 +355,7 @@ final class SendModel: ObservableObject {
|
||||
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
destination: .invitation(accessPolicy: current.accessPolicy)
|
||||
)
|
||||
switch result {
|
||||
switch result.map(\.share) {
|
||||
case .success(let share):
|
||||
await fileSystemService.discardPickedFiles(current.selectedFiles)
|
||||
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {
|
||||
|
||||
@@ -176,6 +176,12 @@ 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
|
||||
if section == .about || section == .bugReport {
|
||||
|
||||
@@ -93,7 +93,9 @@ struct SettingsScreen: View {
|
||||
// Contacts brings its own Form and push destination, so it is not wrapped
|
||||
// in the shared section chrome.
|
||||
if section == .contacts {
|
||||
ContactsScreen(model: contacts)
|
||||
ContactsScreen(model: contacts) {
|
||||
model.reportNothingWaiting()
|
||||
}
|
||||
} else {
|
||||
settingsSectionForm(section)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ struct IosFileSystemService: FileSystemService {
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
destination: ShareDestination
|
||||
) async -> Result<Share, Error> {
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
@@ -70,6 +70,7 @@ struct IosFileSystemService: FileSystemService {
|
||||
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,
|
||||
|
||||
@@ -40,7 +40,7 @@ struct MacFileSystemService: FileSystemService {
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
destination: ShareDestination
|
||||
) async -> Result<Share, Error> {
|
||||
) async -> Result<ContactSendOutcome, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
@@ -68,6 +68,7 @@ struct MacFileSystemService: FileSystemService {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user