Files
vnidrop/apple/VniDrop/Core/CoreRepository.swift
cdricms 8bb1442338 feat(apple): saved devices and targeted transfers UI
Adds the native SwiftUI Saved Devices experience on top of the production
saved-device core, as a top-level destination in the iOS tab bar and the
macOS sidebar.

Core seam:
- App-facing saved-device domain models mirroring core/SavedDeviceModels.kt,
  with lifecycle helpers (canReceive/canResume/canCancel/canDelete) so views
  never hand-roll state checks.
- 21 gateway methods through CoreGateway/CoreRepository with UniFFI mapping.
  cancelTargetedTransfer, forgetSavedDevice and blockDevice run off the serial
  lane: each must reach the core while a targeted receive is blocking it.
- Payload-free pairingChanged/targetedTransferChanged signals, dispatched
  before the numeric-transferId guard since saved-device events identify
  their subject by peer endpoint or a string transfer id.

Experience:
- Screen lists saved devices and outstanding consent requests only; the
  global targeted-transfer history stays out, reachable per device.
- Details as a sheet with detents on compact layouts and a native inspector
  on macOS, owning Send, label, forget/block and that device's transfers.
- Label editing is transactional: the draft and editor survive a failed
  write, conflicting actions are refused while saving, and the editor closes
  only after the core confirms.
- Pairing and targeted-offer consent hosted at the app root, answerable from
  any tab and suppressed while a transfer approval is up. Dismissing a
  pairing prompt suppresses locally without consuming the single-use
  eligibility; dismissing an offer declines it, since an unanswered offer
  holds a slot in the core's bounded per-sender queue.
- Targeted send reuses the invitation composer's affordances with file,
  folder, rename, replace and cleanup parity. Picker copies are released on
  replace/remove/clear/cancel and after a successful create, but kept after a
  failure so retry does not require re-picking.
- Notifications for pairing requests and offers (withdrawn once answered) and
  for terminal targeted transfers. Wording follows direction: on the sending
  device the peer finished receiving, not us.

Localization:
- Widens 52 saved-device keys from kmp-only to both platforms.
- Five keys carried a literal %1$s with no declared args, which Compose
  renders positionally but the Apple generator emits as a plain constant,
  leaking the placeholder into the UI. They now use named args; Compose
  output is byte-identical.
- Adds targeted_offer_title/body. Reusing the invitation approval copy stated
  the roles backwards, announcing the sender as the receiver.

Also surfaces core startup failures: the startup overlay is drawn above the
snackbar host, so a failed initialize() was indistinguishable from an app
that never finished loading. AppModel now keeps the reason, logs it, and the
overlay shows it with a retry, plus the technical detail in DEBUG builds.

Send and receive between two devices is verified only partially; a missing
endpoint-identity credential currently blocks startup on the test device.
2026-08-13 19:42:37 +02:00

767 lines
24 KiB
Swift

import Foundation
import Combine
@preconcurrency import VnidropCore
enum CoreNetworkLifecycleError: Error, Equatable, LocalizedError, Sendable {
case transitionInProgress
case activeNetworkWork
var errorDescription: String? {
switch self {
case .transitionInProgress: return "A network restart is already in progress."
case .activeNetworkWork: return "Stop active transfers and shares before restarting the network."
}
}
}
enum CoreNetworkLifecycle {
nonisolated static func requireIdle(activeTransfers: UInt64, activeShares: UInt64) throws {
guard activeTransfers == 0, activeShares == 0 else {
throw CoreNetworkLifecycleError.activeNetworkWork
}
}
}
protocol CoreBindingFactory: Sendable {
func initialize(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore
}
struct NativeCoreBindingFactory: CoreBindingFactory {
func initialize(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
let nativeConfiguration: CoreNetworkConfig
switch networkConfiguration.mode {
case .automatic:
nativeConfiguration = defaultCoreNetworkConfig()
case .strictCustom:
nativeConfiguration = CoreNetworkConfig(
mode: .strictCustom,
relayUrls: networkConfiguration.relayURLs
)
case .customWithDirectFallback:
nativeConfiguration = CoreNetworkConfig(
mode: .customWithDirectFallback,
relayUrls: networkConfiguration.relayURLs
)
case .localOnly:
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
}
return try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: appDataDir,
eventSink: eventSink,
limits: defaultCoreLimits(),
networkConfig: nativeConfiguration
)
}
}
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
/// generated UniFFI records into app domain models, publishes an observable
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
///
/// UniFFI calls block (the core drives its own runtime via `block_on`), so they
/// run on a background queue and results are hopped back to the main actor.
@MainActor
final class CoreRepository: ObservableObject, CoreGateway {
@Published private(set) var state = CoreState()
var statePublisher: AnyPublisher<CoreState, Never> { $state.eraseToAnyPublisher() }
private let signalsSubject = PassthroughSubject<CoreSignal, Never>()
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
// Initialization swaps happen on `queue`; shutdown and snapshot reads may also
// access the handle from the main actor. The underlying core is internally
// synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6.
private nonisolated(unsafe) var core: VnidropCore?
// Core calls run through `dispatcher` (see runCore/runInterrupt); the factory
// and transition flag drive relay-aware (re)initialization.
private let dispatcher = CoreDispatcher()
private let coreFactory: any CoreBindingFactory
private var isNetworkTransitionInProgress = false
private lazy var sink = RepositoryEventSink { [weak self] event in
Task { @MainActor in self?.handle(event: event) }
}
private nonisolated static let maxEvents = 200
init(coreFactory: any CoreBindingFactory = NativeCoreBindingFactory()) {
self.coreFactory = coreFactory
}
// MARK: - Lifecycle
func initialize(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
isNetworkTransitionInProgress = true
defer { isNetworkTransitionInProgress = false }
let result = await runCore { [sink] in
if let existing = self.core {
let status = existing.status()
try CoreNetworkLifecycle.requireIdle(
activeTransfers: status.activeTransfers,
activeShares: status.activeShares
)
existing.shutdown()
self.core = nil
}
let created = try self.coreFactory.initialize(
appDataDir: appDataDir,
eventSink: sink,
networkConfiguration: networkConfiguration
)
self.core = created
return created
}
switch result {
case .success:
self.refreshSnapshot()
self.state.isInitialized = true
return .success(())
case .failure(let error):
if error as? CoreNetworkLifecycleError != .activeNetworkWork {
self.state = CoreState()
}
return .failure(error)
}
}
func shutdown() {
core?.shutdown()
core = nil
state = CoreState()
}
// MARK: - Share
func shareSources(
_ sources: [ShareSource],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
guard !sources.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
return await runCore {
let result = try self.requireCore().shareFiles(
sources: sources,
metadata: ShareMetadataInput(
transferId: Self.nextTransferId(),
transferName: transferName.isEmpty ? nil : transferName,
senderName: senderName.isEmpty ? nil : senderName,
accessMode: accessPolicy.toNative()
)
)
return result.toModel()
}.map { share in
self.refreshSnapshot()
self.state.lastShare = share
return share
}
}
// MARK: - Inspect / Receive
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> {
await runCore {
try self.requireCore().inspectTicket(ticket: ticket).toModel()
}.map { inspection in
self.state.lastInspection = inspection
return inspection
}
}
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
return await runCore {
try self.requireCore().receive(
ticket: ticket,
outputDir: outputDir,
receiverName: receiverName.isEmpty ? nil : receiverName
)
}.map { self.refreshSnapshot() }
}
/// Receive into a security-scoped directory URL, holding access while the core
/// streams (mirrors `receiveIntoSecurityScopedDirectory`).
func receiveIntoSecurityScopedDirectory(
ticket: String,
outputDirectoryUrl: String,
receiverName: String
) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
return await runCore {
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
try self.requireCore().receive(
ticket: ticket,
outputDir: outputDirectoryUrl,
receiverName: receiverName.isEmpty ? nil : receiverName
)
}
}.map { self.refreshSnapshot() }
}
// MARK: - Lifecycle actions
func cancel(transferId: UInt64) async -> Result<Void, Error> {
// Off the serial `queue`: a receive in flight is blocking it, and the
// cancel signal must reach the core to unblock that receive.
await runInterrupt {
try self.requireCore().cancelTransfer(transferId: transferId)
}.map { self.refreshSnapshot() }
}
func delete(transferId: UInt64) async -> Result<Void, Error> {
await runCore {
try self.requireCore().deleteTransfer(transferId: transferId)
}.map {
self.refreshSnapshot()
self.signalsSubject.send(.approvalChanged(transferId: transferId))
self.signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
}
}
func clearReceiveHistory() async -> Result<UInt64, Error> {
await runCore {
try self.requireCore().deleteReceiveHistory()
}.map { deleted in
self.refreshSnapshot()
return deleted
}
}
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
await runCore {
let usage = try self.requireCore().storageUsage()
return CoreStorageUsageModel(
blobStoreBytes: usage.blobStoreBytes,
appDataBytes: usage.databaseBytes + usage.logsBytes + usage.previewsBytes + usage.otherCoreBytes
)
}
}
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> {
await runCore {
try self.requireCore().listReceivedArtifacts().compactMap { artifact in
guard artifact.locatorKind == .filesystemPath else { return nil }
return ReceivedArtifactModel(locator: artifact.locator, logicalSize: artifact.logicalSize)
}
}
}
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
await runCore {
try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() }
}
}
func respondReceiverRequest(
requestId: String,
accepted: Bool,
reason: String? = nil
) async -> Result<Void, Error> {
await runCore {
try self.requireCore().respondReceiverRequest(requestId: requestId, accepted: accepted, reason: reason)
}
}
func refresh() async -> Result<Void, Error> {
// Read from the core off the main actor, then apply the snapshot on the
// main actor so `@Published state` is never mutated from `queue`.
await runCore { self.readSnapshot() }.map { snapshot in
if let snapshot { self.applySnapshot(snapshot) }
}
}
// MARK: - Saved devices
func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error> {
await runCore { try self.requireCore().listPairingEligibilities().map { $0.toModel() } }
}
func declinePairingEligibility(peerEndpointId: String) async -> Result<Void, Error> {
await runCore { try self.requireCore().declinePairingEligibility(peerEndpointId: peerEndpointId) }
}
func requestSavedDevicePairing(peerEndpointId: String) async -> Result<Bool, Error> {
await runCore { try self.requireCore().requestSavedDevicePairing(peerEndpointId: peerEndpointId) }
}
func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result<Bool, Error> {
await runCore {
try self.requireCore().respondToDevicePairing(peerEndpointId: peerEndpointId, accepted: accepted)
}
}
func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error> {
await runCore { try self.requireCore().listDeviceRelationships().map { $0.toModel() } }
}
func listSavedDevices() async -> Result<[SavedDeviceModel], Error> {
await runCore { try self.requireCore().listSavedDevices().map { $0.toModel() } }
}
func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result<Void, Error> {
await runCore { try self.requireCore().setSavedDeviceLabel(peerEndpointId: peerEndpointId, label: label) }
}
func forgetSavedDevice(peerEndpointId: String) async -> Result<Void, Error> {
// Forget cancels the peer's active/resumable targeted transfers, so it can
// run while one is streaming on the serial lane same reasoning as `cancel`.
await runInterrupt { try self.requireCore().forgetSavedDevice(peerEndpointId: peerEndpointId) }
}
func blockDevice(peerEndpointId: String) async -> Result<Void, Error> {
// Block is immediate and identity-wide, cancelling traffic in flight.
await runInterrupt { try self.requireCore().blockDevice(peerEndpointId: peerEndpointId) }
}
func unblockDevice(peerEndpointId: String) async -> Result<Void, Error> {
await runCore { try self.requireCore().unblockDevice(peerEndpointId: peerEndpointId) }
}
func listBlockedDevices() async -> Result<[String], Error> {
await runCore { try self.requireCore().listBlockedDevices() }
}
// MARK: - Targeted transfers
func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error> {
// `listPendingTargetedOffers` is itself non-throwing, but `requireCore` is.
await runCore { try self.requireCore().listPendingTargetedOffers().map { $0.toModel() } }
}
func respondToTargetedOffer(
transferId: String,
accepted: Bool
) async -> Result<TargetedOfferResponseModel, Error> {
await runCore {
try self.requireCore()
.respondToTargetedOffer(transferId: transferId, accepted: accepted)
.toModel()
}
}
func createTargetedTransfer(
receiverEndpointId: String,
sources: [ShareSource],
transferName: String?
) async -> Result<TargetedTransferModel, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
guard !sources.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
return await runCore {
try self.requireCore().createTargetedTransfer(
receiverEndpointId: receiverEndpointId,
sources: sources,
transferName: transferName
).toModel()
}
}
func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> {
await runCore { try self.requireCore().listTargetedTransfers().map { $0.toModel() } }
}
func receiveTargetedTransfer(
transferId: String,
outputDirectoryUrl: String
) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
return await runCore {
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
try self.requireCore().receiveTargetedTransfer(
transferId: transferId,
outputDir: outputDirectoryUrl
)
}
}
}
func resumeTargetedTransfer(
id: String,
outputDirectoryUrl: String
) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
return await runCore {
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
try self.requireCore().resumeTargetedTransfer(id: id, outputDir: outputDirectoryUrl)
}
}
}
func cancelTargetedTransfer(id: String) async -> Result<Void, Error> {
// Off the serial lane: an in-flight targeted receive is blocking it, and the
// cancel must reach the core to unblock that receive (see `cancel`).
await runInterrupt { try self.requireCore().cancelTargetedTransfer(id: id) }
}
func deleteTargetedTransfer(id: String) async -> Result<Void, Error> {
await runCore { try self.requireCore().deleteTargetedTransfer(id: id) }
}
// MARK: - Event sink handling (ported from CoreRepository.sink)
private func handle(event: CoreEvent) {
let model = event.toModel()
var events = state.events
events.insert(model, at: 0)
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
state.events = events
// Saved-device events identify their subject by peer endpoint or a string
// `targeted_transfer_id`, so they carry no numeric `transferId` and must be
// dispatched before the guard below. Both are payload-free wake-ups: the
// consumer re-reads durable state rather than trusting the event.
switch model.eventPhase {
case .pairing:
signalsSubject.send(.pairingChanged)
return
case .targetedTransfer:
signalsSubject.send(.targetedTransferChanged)
return
default:
break
}
guard let transferId = model.transferId else { return }
switch model.phase {
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
case "delivery": signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
default: break
}
if model.shouldRefreshTransfers {
signalsSubject.send(.transfersChanged(transferId: transferId))
}
}
// MARK: - Internals
/// Snapshot of the values read from the core in one pass.
private struct CoreSnapshot: Sendable {
let status: CoreStatus
let transfers: [Transfer]
let events: [CoreEventModel]
}
/// Reads the current core state. Safe to call off the main actor (pure core
/// FFI reads); does not touch `@Published` state.
private nonisolated func readSnapshot() -> CoreSnapshot? {
guard let core = self.core else { return nil }
let status = core.status()
let transfers = (try? core.listTransfers())?.map { $0.toModel() } ?? []
let events = (try? core.listEvents(transferId: nil))?.prefix(Self.maxEvents).map { $0.toModel() } ?? []
return CoreSnapshot(
status: CoreStatus(
endpointId: status.endpointId,
activeTransfers: status.activeTransfers,
activeShares: status.activeShares
),
transfers: transfers,
events: Array(events)
)
}
/// Applies a snapshot to `@Published state`. Must run on the main actor.
private func applySnapshot(_ snapshot: CoreSnapshot) {
state.status = snapshot.status
state.transfers = snapshot.transfers
state.events = snapshot.events
}
private func refreshSnapshot() {
if let snapshot = readSnapshot() { applySnapshot(snapshot) }
}
private nonisolated func requireCore() throws -> VnidropCore {
guard let core = self.core else {
throw InvitationError.coreNotInitialized
}
return core
}
/// Runs a blocking core call off the main actor and hops the result back.
private nonisolated func runCore<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
await dispatcher.run(block)
}
/// Like `runCore`, but off the serial lane so it can interrupt a blocking
/// call in flight there (e.g. cancel a `receive`). Only use for core calls
/// that are safe to run concurrently with another core call.
private nonisolated func runInterrupt<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
await dispatcher.runInterrupt(block)
}
private nonisolated static func nextTransferId() -> UInt64 {
UInt64.random(in: 1...UInt64(Int64.max))
}
}
/// Event sink bridged to the repository. `onEvent` is invoked on core-owned
/// threads; the handler hops to the main actor.
private final class RepositoryEventSink: CoreEventSink, @unchecked Sendable {
private let handler: @Sendable (CoreEvent) -> Void
init(handler: @escaping @Sendable (CoreEvent) -> Void) { self.handler = handler }
func onEvent(event: CoreEvent) { handler(event) }
}
/// Runs `body` while holding security-scoped access to a bookmarked URL/path.
private func withSecurityScopedAccess<T>(pathOrUrl: String, _ body: () throws -> T) throws -> T {
let url = URL(string: pathOrUrl) ?? URL(fileURLWithPath: pathOrUrl)
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
return try body()
}
// MARK: - Mapping (ported from CoreRepository.kt)
private extension CoreEvent {
func toModel() -> CoreEventModel {
CoreEventModel(
id: id, revision: revision, timestamp: timestamp, scope: scope, transferId: transferId,
direction: direction, phase: phase, kind: kind, dataJson: dataJson
)
}
}
private let refreshPhases: Set<EventPhase> = [.lifecycle, .error, .ticket, .importing, .download, .export, .handshake]
private let refreshKinds: Set<EventKind> = [
.started, .done, .created, .failed, .cancelled, .shareStopped, .foundCollection, .connected,
]
private extension CoreEventModel {
var shouldRefreshTransfers: Bool {
guard let eventPhase, let eventKind else { return false }
return refreshPhases.contains(eventPhase) && refreshKinds.contains(eventKind)
}
}
extension ShareAccessPolicy {
func toNative() -> TransferAccessMode {
switch self {
case .requireApproval: return .approvalRequired
case .anyoneWithTransfer: return .public
}
}
}
private extension TransferAccessMode {
func toModel() -> ShareAccessPolicy {
switch self {
case .approvalRequired: return .requireApproval
case .public: return .anyoneWithTransfer
}
}
}
private extension StoredTransfer {
func toModel() -> Transfer {
Transfer(
localId: localId,
transferId: transferId,
direction: Self.direction(direction),
status: Self.status(status),
peerId: peerId,
transferName: transferName,
contentHash: contentHash,
fileCount: fileCount,
totalSize: totalSize,
ticket: ticket,
accessPolicy: accessMode.toModel(),
createdAt: createdAt,
updatedAt: updatedAt
)
}
static func direction(_ raw: String) -> TransferDirection {
switch raw {
case "send": return .send
case "receive": return .receive
default: return .send
}
}
static func status(_ raw: String) -> TransferStatus {
switch raw {
case "importing": return .importing
case "sharing": return .sharing
case "receiving": return .receiving
case "done": return .done
case "failed": return .failed
case "cancelled": return .cancelled
case "stopped": return .stopped
default: return .failed
}
}
}
private extension ShareResult {
func toModel() -> Share {
Share(
transferId: transferId, ticket: ticket, transferName: transferName,
contentHash: hash, fileCount: fileCount, totalSize: totalSize
)
}
}
private extension TicketInspection {
func toModel() -> TicketInspectionModel {
TicketInspectionModel(kind: kind, metadata: metadata.toModel())
}
}
private extension TransferMetadata {
func toModel() -> TransferMetadataModel {
TransferMetadataModel(
transferId: transferId, transferName: transferName, senderName: senderName,
contentHash: contentHash, fileCount: fileCount, totalSize: totalSize
)
}
}
// MARK: - Saved-device mapping (ported from CoreRepository.kt)
private extension SavedDevice {
func toModel() -> SavedDeviceModel {
SavedDeviceModel(
endpointId: endpointId, localLabel: localLabel, remoteDisplayName: remoteDisplayName,
createdAt: createdAt, lastAuthenticatedAt: lastAuthenticatedAt
)
}
}
private extension DeviceRelationshipState {
func toModel() -> DeviceRelationshipStateModel {
switch self {
case .pendingOutgoing: return .pendingOutgoing
case .pendingIncoming: return .pendingIncoming
case .saved: return .saved
case .revoked: return .revoked
case .blocked: return .blocked
}
}
}
private extension DeviceRelationship {
func toModel() -> DeviceRelationshipModel {
DeviceRelationshipModel(
remoteEndpointId: remoteEndpointId, state: state.toModel(), generation: generation,
minimumProtocolVersion: minimumProtocolVersion, createdAt: createdAt, updatedAt: updatedAt
)
}
}
private extension PairingEligibilitySummary {
func toModel() -> PairingEligibilityModel {
PairingEligibilityModel(
peerEndpointId: peerEndpointId, remoteDisplayName: remoteDisplayName, sessionId: sessionId,
protocolVersion: protocolVersion, createdAt: createdAt, expiresAt: expiresAt
)
}
}
private extension PendingTargetedOffer {
func toModel() -> PendingTargetedOfferModel {
PendingTargetedOfferModel(
transferId: transferId, senderEndpointId: senderEndpointId,
receiverEndpointId: receiverEndpointId, manifestId: manifestId, contentHash: contentHash,
transferName: transferName, fileCount: fileCount, totalSize: totalSize,
protocolVersion: protocolVersion, receivedAt: receivedAt
)
}
}
private extension TargetedTransferState {
func toModel() -> TargetedTransferStateModel {
switch self {
case .preparing: return .preparing
case .offering: return .offering
case .awaitingApproval: return .awaitingApproval
case .approved: return .approved
case .connecting: return .connecting
case .transferring: return .transferring
case .interrupted: return .interrupted
case .completed: return .completed
case .declined: return .declined
case .cancelled: return .cancelled
case .failed: return .failed
case .deleted: return .deleted
}
}
}
private extension TargetedTransfer {
func toModel() -> TargetedTransferModel {
TargetedTransferModel(
id: id, senderEndpointId: senderEndpointId, receiverEndpointId: receiverEndpointId,
manifestId: manifestId, transferName: transferName, fileCount: fileCount,
totalSize: totalSize, verifiedBytes: verifiedBytes, state: state.toModel(),
createdAt: createdAt, updatedAt: updatedAt
)
}
}
private extension TargetedOfferResponse {
func toModel() -> TargetedOfferResponseModel {
switch self {
case .approved(let transferId): return .approved(transferId: transferId)
case .declined: return .declined
case .alreadySettled(let transferId): return .alreadySettled(transferId: transferId)
}
}
}
private extension ReceiverRequest {
func toModel() -> ReceiverRequestModel {
ReceiverRequestModel(
id: id, transferId: transferId, remoteEndpointId: remoteEndpointId,
transferName: transferName, receiverName: receiverName, receiverDeviceName: receiverDeviceName,
appVersion: appVersion, status: Self.status(status), reason: reason,
requestedAt: requestedAt, respondedAt: respondedAt, completedAt: completedAt
)
}
static func status(_ raw: String) -> ReceiverDeliveryStatus {
switch raw {
case "requested": return .requested
case "accepted": return .accepted
case "refused": return .refused
case "expired": return .expired
case "completed": return .completed
case "failed": return .failed
default: return .unknown
}
}
}