Files
vnidrop/apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.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

249 lines
7.6 KiB
Swift

import SwiftUI
import SFSafeSymbols
/// Saved devices screen. A native `List` of saved devices and outstanding consent
/// requests; selecting one opens the per-device details surface. The global
/// targeted-transfer history is deliberately absent transfers belong to a
/// device, and are reachable only through it.
struct SavedDevicesScreen: View {
@ObservedObject var model: SavedDevicesModel
let windowClass: WindowClass
/// Endpoint ID of the device whose details are open.
@State private var selectedPeerId: String?
private var state: SavedDevicesState { model.state }
var body: some View {
NavigationStack {
Group {
if state.isLoading && state.isEmpty {
loadingState
} else if state.loadFailed && state.isEmpty {
failedState
} else if state.isEmpty {
emptyState
} else {
deviceList
}
}
// Title-only header once populated: explanatory copy belongs in the
// first-use empty state, not above a list the user already understands.
.navigationTitle(Text(String(localized: L10n.Saved.devicesListTitle)))
}
// The consent prompts are hosted at the app root, not here: they must be
// answerable from any tab, not only while this screen is showing.
.savedDeviceDetails(model: model, windowClass: windowClass, selectedPeerId: $selectedPeerId)
}
// MARK: - List
private var deviceList: some View {
List {
if !state.eligibilities.isEmpty || !state.pendingRelationships.isEmpty {
Section {
ForEach(state.pendingRelationships) { relationship in
PendingRelationshipRow(
relationship: relationship,
busy: state.busyPeerIds.contains(relationship.remoteEndpointId),
onAccept: { model.acceptIncoming(relationship.remoteEndpointId) },
onDecline: { model.declineIncoming(relationship.remoteEndpointId) }
)
}
ForEach(pendingEligibilities) { eligibility in
EligibilityRow(
eligibility: eligibility,
busy: state.busyPeerIds.contains(eligibility.peerEndpointId),
onRemember: { model.rememberEligible(eligibility.peerEndpointId) },
onDecline: { model.declineEligible(eligibility.peerEndpointId) }
)
}
} header: {
Text(String(localized: L10n.Saved.devicesAttentionTitle))
}
}
if !state.savedDevices.isEmpty {
Section {
ForEach(state.savedDevices) { device in
Button { selectedPeerId = device.endpointId } label: {
SavedDeviceRow(
device: device,
transferCount: state.transfers(for: device.endpointId).count,
busy: state.busyPeerIds.contains(device.endpointId)
)
}
.buttonStyle(.plain)
.contextMenu { deviceMenu(device) }
}
} header: {
Text(String(localized: L10n.Saved.devicesPendingTitle))
}
}
}
}
/// Eligibilities whose peer already has a pending relationship are represented
/// by that relationship row instead, so the same device never appears twice.
private var pendingEligibilities: [PairingEligibilityModel] {
let pendingIds = Set(state.pendingRelationships.map(\.remoteEndpointId))
return state.eligibilities.filter { !pendingIds.contains($0.peerEndpointId) }
}
@ViewBuilder
private func deviceMenu(_ device: SavedDeviceModel) -> some View {
Button {
selectedPeerId = device.endpointId
} label: {
Label(String(localized: L10n.Saved.devicesSendAction), systemSymbol: .paperplane)
}
Button {
selectedPeerId = device.endpointId
model.openLabelEditor(device.endpointId)
} label: {
Label(String(localized: L10n.Saved.devicesLabelAction), systemSymbol: .pencil)
}
}
// MARK: - Placeholder states
private var loadingState: some View {
VStack(spacing: 12) {
ProgressView()
Text(String(localized: L10n.Saved.devicesLoading))
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var failedState: some View {
ContentUnavailableView {
Label(String(localized: L10n.Saved.devicesLoadFailed), systemSymbol: .exclamationmarkTriangleFill)
} actions: {
Button(String(localized: L10n.Button.retry), action: model.retry)
.buttonStyle(.borderedProminent)
}
}
/// First-use state: the only place that explains what a saved device is.
private var emptyState: some View {
ContentUnavailableView {
Label(String(localized: L10n.Saved.devicesEmptyTitle), systemSymbol: .macbookAndIphone)
} description: {
Text(String(localized: L10n.Saved.devicesEmpty))
}
}
}
// MARK: - Rows
private struct SavedDeviceRow: View {
let device: SavedDeviceModel
let transferCount: Int
let busy: Bool
var body: some View {
HStack(spacing: 12) {
DeviceAvatar()
VStack(alignment: .leading, spacing: 3) {
Text(device.displayName)
.font(.body)
.lineLimit(1)
.truncationMode(.tail)
EndpointIdLabel(endpointId: device.endpointId)
}
Spacer(minLength: 8)
if busy {
ProgressView().controlSize(.small)
} else if transferCount > 0 {
Text(verbatim: "\(transferCount)")
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Image(systemSymbol: .chevronRight)
.font(.caption)
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
}
/// A relationship awaiting consent. Incoming requests get accept/decline; an
/// outgoing request is informational until the peer answers.
private struct PendingRelationshipRow: View {
let relationship: DeviceRelationshipModel
let busy: Bool
let onAccept: () -> Void
let onDecline: () -> Void
private var isIncoming: Bool { relationship.state == .pendingIncoming }
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
DeviceAvatar(symbol: .personBadgeClock, tint: .orange)
VStack(alignment: .leading, spacing: 3) {
Text(String(localized: isIncoming
? L10n.Saved.devicesPendingIncoming
: L10n.Saved.devicesPendingOutgoing))
.font(.body)
.lineLimit(2)
EndpointIdLabel(endpointId: relationship.remoteEndpointId)
}
Spacer(minLength: 0)
if busy { ProgressView().controlSize(.small) }
}
if isIncoming {
HStack(spacing: 10) {
Button(String(localized: L10n.Saved.devicesAcceptPairingAction), action: onAccept)
.buttonStyle(.borderedProminent)
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: onDecline)
.buttonStyle(.bordered)
}
.controlSize(.small)
.disabled(busy)
}
}
.padding(.vertical, 4)
}
}
/// A peer we completed a transfer with and may ask to pair. Naming it uses the
/// peer's untrusted hint the only name available before the device is saved.
private struct EligibilityRow: View {
let eligibility: PairingEligibilityModel
let busy: Bool
let onRemember: () -> Void
let onDecline: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
DeviceAvatar(symbol: .checkmarkSealFill, tint: VniDropColors.brandPurple)
VStack(alignment: .leading, spacing: 3) {
Text(eligibility.remoteDisplayName ?? String(localized: L10n.Saved.devicesUnnamed))
.font(.body)
.lineLimit(1)
Text(String(localized: L10n.Saved.devicesEligibilityTitle))
.font(.caption)
.foregroundStyle(.secondary)
EndpointIdLabel(endpointId: eligibility.peerEndpointId)
}
Spacer(minLength: 0)
if busy { ProgressView().controlSize(.small) }
}
HStack(spacing: 10) {
Button(String(localized: L10n.Saved.devicesRememberAction), action: onRemember)
.buttonStyle(.borderedProminent)
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: onDecline)
.buttonStyle(.bordered)
}
.controlSize(.small)
.disabled(busy)
}
.padding(.vertical, 4)
}
}