mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 22:29:58 +02:00
Creating a targeted transfer contacts the peer and only returns once the offer is answered or its timeouts expire — connection_timeout plus offer_wait_timeout, so minutes against a device that never answers. The composer disabled every control for that whole window, including Close, leaving no way out. Worse, the core records the row before it reaches out and leaves it in `failed` when the peer never replies, so giving up still produced a failure notification and a history entry for a send the user had already called off. Cancelling now reaches the core while that create is still running. The `created` lifecycle event carries the transfer id and is emitted before the peer is contacted, so the id is known in time; the cancel goes out through the interrupt lane, which exists precisely to reach a core busy inside another call. The transfer is then deleted, and its id is filtered out of the published list so a refresh racing the delete cannot leak it into history or into a notification. If the id has not arrived yet, the result carries it and the same cleanup runs on return. Picked sources are released only once the call lands, because the import owns them until then, and a generation counter keeps a late result from disturbing a newer send. Close and Cancel were also the same action under two labels. There is now one control: the sheet's cancellation item, reading Cancel while a send is waiting and Close otherwise, never disabled. Two further fixes from device testing: - Receive and Resume were gated on transfer state alone, so an approved outgoing transfer offered the sender a Receive button — an invitation to download the files it was uploading. Both pull into a local folder and are now receiver-only. - Renamed the deprecated `laptopcomputerAndIphone` symbol to `macbookAndIphone`; the deployment targets are well past where it was introduced, so no availability guard is needed. Adds a typed `targetedTransferId` accessor on CoreEventModel. This is a narrow, deliberate exception to the wake-up-only event rule in DESIGN-DEVICE-HISTORY.md §13: it takes the subject id and never state, and it exists because no query can answer while the create holds the serial lane — which is exactly when the user wants to cancel. Known gap: direction is inferred by comparing endpoint ids, because the binding does not expose the row's role. After an identity reset, rows predating it match neither endpoint, so past sends read as incoming from the device's own retired identity. Fixing that needs `role` on the core's TargetedTransfer.
232 lines
6.4 KiB
Swift
232 lines
6.4 KiB
Swift
import Foundation
|
|
|
|
/// App-facing domain models, ported from `core/CoreModels.kt`. The repository maps
|
|
/// the generated UniFFI records/enums into these so the UI never depends on the
|
|
/// binding surface directly.
|
|
|
|
struct CoreStatus: Equatable, Sendable {
|
|
let endpointId: String
|
|
let activeTransfers: UInt64
|
|
let activeShares: UInt64
|
|
}
|
|
|
|
struct CoreEventModel: Equatable, Identifiable, Sendable {
|
|
let id: String
|
|
let revision: UInt64
|
|
let timestamp: Int64
|
|
let scope: String
|
|
let transferId: UInt64?
|
|
/// Raw wire values as emitted by the core. Interpret them through the typed
|
|
/// `eventDirection` / `eventPhase` / `eventKind` accessors below — logic code
|
|
/// should never compare these strings directly.
|
|
let direction: String?
|
|
let phase: String
|
|
let kind: String
|
|
let dataJson: String
|
|
|
|
var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) }
|
|
var eventPhase: EventPhase? { EventPhase(rawValue: phase) }
|
|
var eventKind: EventKind? { EventKind(rawValue: kind) }
|
|
|
|
/// Subject of a `.targetedTransfer` event.
|
|
///
|
|
/// This is an identifier, not state: it says *which* transfer changed, which
|
|
/// is all a consumer may take from an event before re-reading durable state.
|
|
/// It exists because the id is otherwise unobtainable while `create` is still
|
|
/// running — that call occupies the serial lane, so no query can answer until
|
|
/// it returns, which is exactly when the user wants to cancel it.
|
|
var targetedTransferId: String? {
|
|
guard eventPhase == .targetedTransfer, let data = dataJson.data(using: .utf8) else {
|
|
return nil
|
|
}
|
|
return try? JSONDecoder().decode(TargetedTransferEventData.self, from: data).targetedTransferId
|
|
}
|
|
}
|
|
|
|
/// Payload of a `.targetedTransfer` event. Named keys rather than a raw string
|
|
/// subscript so the wire contract lives in one declared place.
|
|
private struct TargetedTransferEventData: Decodable {
|
|
let targetedTransferId: String?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case targetedTransferId = "targeted_transfer_id"
|
|
}
|
|
}
|
|
|
|
/// Direction of a core event, matching the wire strings the core emits.
|
|
enum EventDirection: String, Equatable, Sendable {
|
|
case send
|
|
case receive
|
|
}
|
|
|
|
/// Phase of a core progress event (the `phase` wire field).
|
|
enum EventPhase: String, Equatable, Sendable {
|
|
case importing = "import"
|
|
case ticket
|
|
case access
|
|
case transfer
|
|
case download
|
|
case export
|
|
case lifecycle
|
|
case network
|
|
case handshake
|
|
case error
|
|
/// Saved-device consent lifecycle (eligibility, relationships, grants).
|
|
case pairing
|
|
/// Targeted-transfer offer and lifecycle. Its events identify the transfer by
|
|
/// a string `targeted_transfer_id`, not the numeric `transferId` used by
|
|
/// invitation shares.
|
|
case targetedTransfer = "targeted_transfer"
|
|
}
|
|
|
|
/// Kind of a core progress event (the `kind` wire field).
|
|
enum EventKind: String, Equatable, Sendable {
|
|
case started
|
|
case copyProgress = "copy-progress"
|
|
case copyDone = "copy-done"
|
|
case outboardProgress = "outboard-progress"
|
|
case done
|
|
case created
|
|
case progress
|
|
case completed
|
|
case aborted
|
|
case failed
|
|
case connecting
|
|
case connected
|
|
case foundCollection = "found-collection"
|
|
case cancelled
|
|
case shareStopped = "share-stopped"
|
|
}
|
|
|
|
enum ShareAccessPolicy: Equatable, Sendable {
|
|
case requireApproval
|
|
case anyoneWithTransfer
|
|
}
|
|
|
|
/// Where a picked selection is going.
|
|
enum ShareDestination: Equatable, Sendable {
|
|
case invitation(accessPolicy: ShareAccessPolicy)
|
|
}
|
|
|
|
enum TransferDirection: Equatable, Sendable {
|
|
case send
|
|
case receive
|
|
}
|
|
|
|
enum TransferStatus: Equatable, Sendable {
|
|
case importing
|
|
case sharing
|
|
case receiving
|
|
case done
|
|
case failed
|
|
case cancelled
|
|
case stopped
|
|
}
|
|
|
|
struct Transfer: Equatable, Identifiable, Sendable {
|
|
let localId: String
|
|
let transferId: UInt64
|
|
let direction: TransferDirection
|
|
let status: TransferStatus
|
|
let peerId: String?
|
|
let transferName: String?
|
|
let contentHash: String?
|
|
let fileCount: UInt64
|
|
let totalSize: UInt64
|
|
let ticket: String?
|
|
let accessPolicy: ShareAccessPolicy
|
|
let createdAt: Int64
|
|
let updatedAt: Int64
|
|
|
|
var id: String { localId }
|
|
}
|
|
|
|
struct Share: Equatable, Sendable {
|
|
let transferId: UInt64
|
|
let ticket: String
|
|
let transferName: String
|
|
let contentHash: String
|
|
let fileCount: UInt64
|
|
let totalSize: UInt64
|
|
}
|
|
|
|
struct TransferMetadataModel: Equatable, Sendable {
|
|
let transferId: UInt64
|
|
let transferName: String
|
|
let senderName: String?
|
|
let contentHash: String
|
|
let fileCount: UInt64
|
|
let totalSize: UInt64
|
|
}
|
|
|
|
struct TicketInspectionModel: Equatable, Sendable {
|
|
let kind: String
|
|
let metadata: TransferMetadataModel
|
|
}
|
|
|
|
enum ReceiverDeliveryStatus: Equatable, Sendable {
|
|
case requested
|
|
case accepted
|
|
case refused
|
|
case expired
|
|
case completed
|
|
case failed
|
|
case unknown
|
|
}
|
|
|
|
struct ReceiverRequestModel: Equatable, Identifiable, Sendable {
|
|
let id: String
|
|
let transferId: UInt64
|
|
let remoteEndpointId: String
|
|
let transferName: String
|
|
let receiverName: String?
|
|
let receiverDeviceName: String?
|
|
let appVersion: String
|
|
let status: ReceiverDeliveryStatus
|
|
let reason: String?
|
|
let requestedAt: Int64
|
|
let respondedAt: Int64?
|
|
let completedAt: Int64?
|
|
}
|
|
|
|
struct CoreState: Equatable, Sendable {
|
|
var isInitialized: Bool = false
|
|
var status: CoreStatus?
|
|
var events: [CoreEventModel] = []
|
|
var transfers: [Transfer] = []
|
|
var lastShare: Share?
|
|
var lastInspection: TicketInspectionModel?
|
|
}
|
|
|
|
/// Coalesced change hints emitted from the event sink, ported from `CoreSignal`.
|
|
enum CoreSignal: Equatable, Sendable {
|
|
case approvalChanged(transferId: UInt64)
|
|
case receiverHistoryChanged(transferId: UInt64)
|
|
/// Transfer status/history changed enough to re-read the durable snapshot.
|
|
case transfersChanged(transferId: UInt64)
|
|
/// Pairing / saved-device state changed; refresh eligibility, relationships,
|
|
/// and the saved list. Carries no payload: core events are wake-ups, not
|
|
/// authoritative state, so consumers re-query rather than apply a delta.
|
|
case pairingChanged
|
|
/// Targeted-transfer offer or lifecycle changed; refresh pending offers and
|
|
/// transfers. Payload-free for the same reason as `pairingChanged`.
|
|
case targetedTransferChanged
|
|
}
|
|
|
|
// MARK: - Transfer helpers (ported from AppUiModels.kt)
|
|
|
|
extension TransferStatus {
|
|
var isActiveTransfer: Bool {
|
|
self == .importing || self == .sharing || self == .receiving
|
|
}
|
|
|
|
var canCancelTransfer: Bool {
|
|
self == .importing || self == .sharing || self == .receiving
|
|
}
|
|
|
|
/// Terminal receive-history states eligible for deletion.
|
|
var isTerminalReceiveHistory: Bool {
|
|
self == .done || self == .failed || self == .cancelled
|
|
}
|
|
}
|