Files
vnidrop/apple/VniDrop/Core/CoreModels.swift
cdricms 6b6d5f158d fix(apple): make cancelling a targeted send actually cancel
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.
2026-08-13 22:33:06 +02:00

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
}
}