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.
This commit is contained in:
2026-08-13 19:42:37 +02:00
parent bece2af179
commit 8bb1442338
33 changed files with 3837 additions and 247 deletions

View File

@@ -81,6 +81,122 @@ final class FakeCoreGateway: CoreGateway {
return responseResult
}
func refresh() async -> Result<Void, Error> { .success(()) }
// MARK: - Saved devices
// Stubbed results
var savedDevices: [SavedDeviceModel] = []
/// Overrides `savedDevices` when set, so a test can fail one leg of the
/// five-read snapshot without stubbing the rest.
var savedDevicesResult: Result<[SavedDeviceModel], Error>?
var deviceRelationships: [DeviceRelationshipModel] = []
var pairingEligibilities: [PairingEligibilityModel] = []
var blockedDevices: [String] = []
var pendingTargetedOffers: [PendingTargetedOfferModel] = []
var targetedTransfers: [TargetedTransferModel] = []
var setLabelResult: Result<Void, Error> = .success(())
var forgetResult: Result<Void, Error> = .success(())
var blockResult: Result<Void, Error> = .success(())
var requestPairingResult: Result<Bool, Error> = .success(true)
var respondToPairingResult: Result<Bool, Error> = .success(true)
var offerResponseResult: Result<TargetedOfferResponseModel, Error> = .success(.declined)
var createTargetedTransferResult: Result<TargetedTransferModel, Error> = .failure(TestError.unimplemented)
var targetedReceiveResult: Result<Void, Error> = .success(())
var targetedCancelResult: Result<Void, Error> = .success(())
var targetedDeleteResult: Result<Void, Error> = .success(())
// Recorded calls
private(set) var setLabels: [(peerEndpointId: String, label: String?)] = []
private(set) var forgottenDevices: [String] = []
private(set) var blockedCalls: [String] = []
private(set) var unblockedCalls: [String] = []
private(set) var declinedEligibilities: [String] = []
private(set) var requestedPairings: [String] = []
private(set) var pairingResponses: [(peerEndpointId: String, accepted: Bool)] = []
private(set) var offerResponses: [(transferId: String, accepted: Bool)] = []
private(set) var createdTargetedTransfers: [(receiverEndpointId: String, sources: [ShareSource], transferName: String?)] = []
private(set) var targetedReceives: [(transferId: String, outputDirectoryUrl: String)] = []
private(set) var targetedResumes: [(id: String, outputDirectoryUrl: String)] = []
private(set) var cancelledTargetedTransfers: [String] = []
private(set) var deletedTargetedTransfers: [String] = []
func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error> {
.success(pairingEligibilities)
}
func declinePairingEligibility(peerEndpointId: String) async -> Result<Void, Error> {
declinedEligibilities.append(peerEndpointId)
return .success(())
}
func requestSavedDevicePairing(peerEndpointId: String) async -> Result<Bool, Error> {
requestedPairings.append(peerEndpointId)
return requestPairingResult
}
func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result<Bool, Error> {
pairingResponses.append((peerEndpointId, accepted))
return respondToPairingResult
}
func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error> {
.success(deviceRelationships)
}
func listSavedDevices() async -> Result<[SavedDeviceModel], Error> {
savedDevicesResult ?? .success(savedDevices)
}
func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result<Void, Error> {
setLabels.append((peerEndpointId, label))
return setLabelResult
}
func forgetSavedDevice(peerEndpointId: String) async -> Result<Void, Error> {
forgottenDevices.append(peerEndpointId)
return forgetResult
}
func blockDevice(peerEndpointId: String) async -> Result<Void, Error> {
blockedCalls.append(peerEndpointId)
return blockResult
}
func unblockDevice(peerEndpointId: String) async -> Result<Void, Error> {
unblockedCalls.append(peerEndpointId)
return .success(())
}
func listBlockedDevices() async -> Result<[String], Error> { .success(blockedDevices) }
// MARK: - Targeted transfers
func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error> {
.success(pendingTargetedOffers)
}
func respondToTargetedOffer(
transferId: String, accepted: Bool
) async -> Result<TargetedOfferResponseModel, Error> {
offerResponses.append((transferId, accepted))
return offerResponseResult
}
func createTargetedTransfer(
receiverEndpointId: String, sources: [ShareSource], transferName: String?
) async -> Result<TargetedTransferModel, Error> {
createdTargetedTransfers.append((receiverEndpointId, sources, transferName))
return createTargetedTransferResult
}
func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> {
.success(targetedTransfers)
}
func receiveTargetedTransfer(
transferId: String, outputDirectoryUrl: String
) async -> Result<Void, Error> {
targetedReceives.append((transferId, outputDirectoryUrl))
return targetedReceiveResult
}
func resumeTargetedTransfer(id: String, outputDirectoryUrl: String) async -> Result<Void, Error> {
targetedResumes.append((id, outputDirectoryUrl))
return targetedReceiveResult
}
func cancelTargetedTransfer(id: String) async -> Result<Void, Error> {
cancelledTargetedTransfers.append(id)
return targetedCancelResult
}
func deleteTargetedTransfer(id: String) async -> Result<Void, Error> {
deletedTargetedTransfers.append(id)
return targetedDeleteResult
}
}
/// Minimal `FileSystemService` fake a writable path receive folder, no reveal.
@@ -103,6 +219,31 @@ final class FakeFileSystemService: FileSystemService {
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
}
/// Records targeted sends and forwards to the gateway so its recorders and
/// stubbed result drive the assertions.
private(set) var targetedSends: [(files: [PickedShareFile], transferName: String, receiver: String)] = []
func sendPickedFilesToSavedDevice(
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
receiverEndpointId: String
) async -> Result<TargetedTransferModel, Error> {
targetedSends.append((files, transferName, receiverEndpointId))
return await repository.createTargetedTransfer(
receiverEndpointId: receiverEndpointId,
sources: [],
transferName: transferName.isEmpty ? nil : transferName
)
}
/// Picker copies released via `discardPickedFiles`.
private(set) var discardedFiles: [String] = []
func discardPickedFiles(_ files: [PickedShareFile]) async {
discardedFiles.append(contentsOf: files.map(\.value))
}
}
@MainActor

View File

@@ -0,0 +1,167 @@
import XCTest
@testable import VniDrop
@MainActor
final class SavedDeviceNotificationTests: XCTestCase {
private static let peer = "peer-endpoint"
private func state(
pendingRelationships: [DeviceRelationshipModel] = [],
eligibilities: [PairingEligibilityModel] = [],
savedDevices: [SavedDeviceModel] = [],
offers: [PendingTargetedOfferModel] = [],
senderDisplayNames: [String: String] = [:],
transfers: [SavedDeviceTransferItem] = []
) -> SavedDevicesState {
var state = SavedDevicesState()
state.isLoading = false
state.pendingRelationships = pendingRelationships
state.eligibilities = eligibilities
state.savedDevices = savedDevices
state.targetedOffers.pending = offers
state.targetedOffers.senderDisplayNames = senderDisplayNames
state.targetedTransfers = transfers
return state
}
private func relationship(_ state: DeviceRelationshipStateModel) -> DeviceRelationshipModel {
DeviceRelationshipModel(
remoteEndpointId: Self.peer, state: state, generation: 1,
minimumProtocolVersion: 1, createdAt: 1, updatedAt: 1
)
}
private func offer(_ transferId: String = "t1") -> PendingTargetedOfferModel {
PendingTargetedOfferModel(
transferId: transferId, senderEndpointId: Self.peer, receiverEndpointId: "me",
manifestId: "m", contentHash: "h", transferName: "Photos", fileCount: 1,
totalSize: 10, protocolVersion: 1, receivedAt: 1
)
}
private func transferItem(
id: String = "t1",
state: TargetedTransferStateModel,
direction: SavedDeviceTransferDirection = .incoming
) -> SavedDeviceTransferItem {
SavedDeviceTransferItem(
id: id, peerEndpointId: Self.peer, peerDisplayName: "Studio Mac",
direction: direction, transferName: "Photos", fileCount: 1, totalSize: 10,
verifiedBytes: 10, state: state, createdAt: 1, updatedAt: 1
)
}
// MARK: - Prompts
func testIncomingPairingRequestIsPlanned() {
let planned = plannedSavedDevicePrompts(state(pendingRelationships: [relationship(.pendingIncoming)]))
XCTAssertEqual(planned.map(\.kind), [.pairingRequest])
XCTAssertEqual(planned.first?.id, "pairing-request-\(Self.peer)")
}
func testOutgoingPairingRequestIsNotPlanned() {
// We are the ones waiting; there is nothing for the user to answer.
let planned = plannedSavedDevicePrompts(state(pendingRelationships: [relationship(.pendingOutgoing)]))
XCTAssertTrue(planned.isEmpty)
}
func testPairingRequestUsesEligibilityNameWhenAvailable() {
let eligibility = PairingEligibilityModel(
peerEndpointId: Self.peer, remoteDisplayName: "Alice's Mac", sessionId: "s",
protocolVersion: 1, createdAt: 1, expiresAt: 2
)
let planned = plannedSavedDevicePrompts(state(
pendingRelationships: [relationship(.pendingIncoming)],
eligibilities: [eligibility]
))
XCTAssertEqual(planned.first?.deviceName, "Alice's Mac")
}
func testPendingOfferIsPlannedWithSenderNameOnlyWhenSaved() {
let unnamed = plannedSavedDevicePrompts(state(offers: [offer()]))
XCTAssertEqual(unnamed.map(\.kind), [.targetedOffer])
// An unsaved sender has no name we can vouch for.
XCTAssertNil(unnamed.first?.deviceName)
let named = plannedSavedDevicePrompts(state(
offers: [offer()],
senderDisplayNames: [Self.peer: "Studio Mac"]
))
XCTAssertEqual(named.first?.deviceName, "Studio Mac")
}
func testPromptIdsAreStablePerSubject() {
// Stable ids are what let the coordinator withdraw a prompt once answered.
let first = plannedSavedDevicePrompts(state(offers: [offer()]))
let second = plannedSavedDevicePrompts(state(offers: [offer()]))
XCTAssertEqual(first.map(\.id), second.map(\.id))
}
// MARK: - Terminal outcomes
func testCompletedAndFailedTransfersArePlanned() {
let planned = plannedTargetedOutcomes(
[transferItem(id: "a", state: .completed), transferItem(id: "b", state: .failed)],
published: []
)
XCTAssertEqual(planned.map(\.kind), [.targetedReceiveCompleted, .targetedReceiveFailed])
}
func testOutcomeWordingFollowsDirection() {
// On the sending device it is the *peer* that finished downloading. Using
// the receive wording here told the sender it had downloaded its own files.
let outgoing = plannedTargetedOutcomes(
[
transferItem(id: "a", state: .completed, direction: .outgoing),
transferItem(id: "b", state: .failed, direction: .outgoing),
],
published: []
)
XCTAssertEqual(outgoing.map(\.kind), [.targetedSendCompleted, .targetedSendFailed])
let incoming = plannedTargetedOutcomes(
[transferItem(id: "c", state: .completed, direction: .incoming)],
published: []
)
XCTAssertEqual(incoming.map(\.kind), [.targetedReceiveCompleted])
}
func testIdsDoNotCollideAcrossDirections() {
let incoming = plannedTargetedOutcomes([transferItem(state: .completed)], published: [])
let outgoing = plannedTargetedOutcomes(
[transferItem(state: .completed, direction: .outgoing)], published: []
)
XCTAssertNotEqual(incoming.first?.id, outgoing.first?.id)
}
func testUserDrivenTerminalStatesAreNotPlanned() {
// The user already knows: they cancelled or declined it themselves.
let planned = plannedTargetedOutcomes(
[
transferItem(id: "a", state: .cancelled),
transferItem(id: "b", state: .declined),
transferItem(id: "c", state: .transferring),
],
published: []
)
XCTAssertTrue(planned.isEmpty)
}
func testAlreadyPublishedOutcomesAreNotReplanned() {
let first = plannedTargetedOutcomes([transferItem(state: .completed)], published: [])
XCTAssertEqual(first.count, 1)
let second = plannedTargetedOutcomes(
[transferItem(state: .completed)],
published: Set(first.map(\.id))
)
XCTAssertTrue(second.isEmpty)
}
}

View File

@@ -0,0 +1,508 @@
import XCTest
import Combine
@testable import VniDrop
@MainActor
final class SavedDevicesModelTests: XCTestCase {
private var gateway: FakeCoreGateway!
private var fileSystem: FakeFileSystemService!
private var messages: UiMessageController!
override func setUp() async throws {
gateway = FakeCoreGateway()
fileSystem = FakeFileSystemService()
messages = UiMessageController()
}
/// Builds the model and lets the initial refresh settle. The model loads on the
/// combined (preferences, isInitialized) signal, so the core must look ready.
private func makeModel() async -> SavedDevicesModel {
var state = CoreState()
state.isInitialized = true
state.status = CoreStatus(endpointId: Self.localEndpoint, activeTransfers: 0, activeShares: 0)
gateway.setState(state)
let model = SavedDevicesModel(
repository: gateway,
fileSystemService: fileSystem,
preferences: Fixtures.preferences(),
messages: messages
)
await waitUntil { !model.state.isLoading }
return model
}
private static let localEndpoint = "local-endpoint"
private static let peer = "peer-endpoint"
private func savedDevice(
_ endpointId: String = peer,
localLabel: String? = nil,
remoteDisplayName: String? = "Remote Name",
createdAt: Int64 = 1
) -> SavedDeviceModel {
SavedDeviceModel(
endpointId: endpointId, localLabel: localLabel, remoteDisplayName: remoteDisplayName,
createdAt: createdAt, lastAuthenticatedAt: nil
)
}
private func transfer(
id: String = "t1",
sender: String = localEndpoint,
receiver: String = peer,
state: TargetedTransferStateModel = .completed,
verifiedBytes: UInt64 = 0,
totalSize: UInt64 = 100,
updatedAt: Int64 = 1
) -> TargetedTransferModel {
TargetedTransferModel(
id: id, senderEndpointId: sender, receiverEndpointId: receiver, manifestId: "m",
transferName: "Photos", fileCount: 2, totalSize: totalSize, verifiedBytes: verifiedBytes,
state: state, createdAt: 1, updatedAt: updatedAt
)
}
private func relationship(
_ endpointId: String = peer,
state: DeviceRelationshipStateModel,
updatedAt: Int64 = 1
) -> DeviceRelationshipModel {
DeviceRelationshipModel(
remoteEndpointId: endpointId, state: state, generation: 1,
minimumProtocolVersion: 1, createdAt: 1, updatedAt: updatedAt
)
}
private func eligibility(
_ endpointId: String = peer,
remoteDisplayName: String? = "Eligible Device",
createdAt: Int64 = 1
) -> PairingEligibilityModel {
PairingEligibilityModel(
peerEndpointId: endpointId, remoteDisplayName: remoteDisplayName, sessionId: "s",
protocolVersion: 1, createdAt: createdAt, expiresAt: createdAt + 86_400
)
}
// MARK: - Snapshot composition
func testLoadsSnapshotAndKeepsTransfersOutOfTheDeviceList() async {
gateway.savedDevices = [savedDevice()]
gateway.deviceRelationships = [relationship(state: .saved)]
gateway.targetedTransfers = [transfer()]
let model = await makeModel()
XCTAssertEqual(model.state.savedDevices.map(\.endpointId), [Self.peer])
// A saved relationship is not "pending" and must not appear as one.
XCTAssertTrue(model.state.pendingRelationships.isEmpty)
// Transfers are reachable only per-device, never as a global list on screen.
XCTAssertEqual(model.state.transfers(for: Self.peer).map(\.id), ["t1"])
}
func testResolvesDirectionAgainstLocalEndpoint() async {
gateway.savedDevices = [savedDevice()]
gateway.targetedTransfers = [
transfer(id: "out", sender: Self.localEndpoint, receiver: Self.peer),
transfer(id: "in", sender: Self.peer, receiver: Self.localEndpoint),
]
let model = await makeModel()
let byId = Dictionary(uniqueKeysWithValues: model.state.targetedTransfers.map { ($0.id, $0) })
XCTAssertEqual(byId["out"]?.direction, .outgoing)
XCTAssertEqual(byId["in"]?.direction, .incoming)
// Either way the peer is the *other* device.
XCTAssertEqual(byId["out"]?.peerEndpointId, Self.peer)
XCTAssertEqual(byId["in"]?.peerEndpointId, Self.peer)
}
func testHidesDeletedTransfers() async {
gateway.targetedTransfers = [transfer(id: "kept"), transfer(id: "gone", state: .deleted)]
let model = await makeModel()
XCTAssertEqual(model.state.targetedTransfers.map(\.id), ["kept"])
}
func testPrefersLocalLabelOverRemoteDisplayName() async {
gateway.savedDevices = [savedDevice(localLabel: "My Laptop", remoteDisplayName: "hostname")]
gateway.targetedTransfers = [transfer()]
let model = await makeModel()
XCTAssertEqual(model.state.targetedTransfers.first?.peerDisplayName, "My Laptop")
}
func testBlankLocalLabelFallsBackToRemoteDisplayName() async {
gateway.savedDevices = [savedDevice(localLabel: " ", remoteDisplayName: "hostname")]
gateway.targetedTransfers = [transfer()]
let model = await makeModel()
XCTAssertEqual(model.state.targetedTransfers.first?.peerDisplayName, "hostname")
}
func testFailedLoadIsReportedRatherThanRenderedPartially() async {
gateway.savedDevicesResult = .failure(TestError.unimplemented)
let model = await makeModel()
XCTAssertTrue(model.state.loadFailed)
XCTAssertTrue(model.state.savedDevices.isEmpty)
}
// MARK: - Pairing prompt
func testIncomingRequestOutranksEligibility() async {
gateway.deviceRelationships = [relationship(state: .pendingIncoming)]
gateway.pairingEligibilities = [eligibility("other-peer")]
let model = await makeModel()
guard case .incomingRequest(let peerId, _) = model.state.pairingPrompt.prompt else {
return XCTFail("expected an incoming request to take priority")
}
XCTAssertEqual(peerId, Self.peer)
}
func testDismissingEligibilityDoesNotConsumeItInTheCore() async {
gateway.pairingEligibilities = [eligibility()]
let model = await makeModel()
XCTAssertNotNil(model.state.pairingPrompt.prompt)
model.dismissPairingPrompt()
XCTAssertNil(model.state.pairingPrompt.prompt)
// Dismissal is local suppression, not a decline: the single-use capability
// must survive so the device stays actionable from the list.
XCTAssertTrue(gateway.declinedEligibilities.isEmpty)
XCTAssertEqual(model.state.eligibilities.count, 1)
}
func testDismissedEligibilityDoesNotReappearOnRefresh() async {
gateway.pairingEligibilities = [eligibility()]
let model = await makeModel()
model.dismissPairingPrompt()
gateway.emit(.pairingChanged)
await waitUntil { !model.state.isLoading }
XCTAssertNil(model.state.pairingPrompt.prompt)
}
func testDecliningEligibilityConsumesItInTheCore() async {
gateway.pairingEligibilities = [eligibility()]
let model = await makeModel()
model.declinePairingPrompt()
await waitUntil { !model.state.pairingPrompt.busy }
XCTAssertEqual(gateway.declinedEligibilities, [Self.peer])
}
// MARK: - Label editing
func testLabelSaveTrimsAndClosesEditorOnSuccess() async {
gateway.savedDevices = [savedDevice()]
let model = await makeModel()
model.openLabelEditor(Self.peer)
model.setLabelDraft(" Studio Mac ")
model.saveLabel()
await waitUntil { !model.state.isSavingLabel }
XCTAssertEqual(gateway.setLabels.map(\.label), ["Studio Mac"])
XCTAssertNil(model.state.labelingPeerId)
XCTAssertEqual(model.state.labelDraft, "")
}
func testLabelFailurePreservesDraftAndEditor() async {
gateway.savedDevices = [savedDevice()]
gateway.setLabelResult = .failure(TestError.unimplemented)
let model = await makeModel()
model.openLabelEditor(Self.peer)
model.setLabelDraft("Studio Mac")
model.saveLabel()
await waitUntil { !model.state.isSavingLabel }
// The retry path depends on both surviving.
XCTAssertEqual(model.state.labelingPeerId, Self.peer)
XCTAssertEqual(model.state.labelDraft, "Studio Mac")
}
func testEditorCannotBeDismissedOrEditedWhileSaving() async {
gateway.savedDevices = [savedDevice()]
let model = await makeModel()
model.openLabelEditor(Self.peer)
model.setLabelDraft("Studio Mac")
model.saveLabel()
// Still in flight: conflicting actions must be refused, not queued.
model.setLabelDraft("Something Else")
model.dismissLabelEditor()
XCTAssertEqual(model.state.labelDraft, "Studio Mac")
XCTAssertEqual(model.state.labelingPeerId, Self.peer)
await waitUntil { !model.state.isSavingLabel }
XCTAssertEqual(gateway.setLabels.map(\.label), ["Studio Mac"])
}
func testClearingLabelSendsNil() async {
gateway.savedDevices = [savedDevice(localLabel: "Old")]
let model = await makeModel()
model.openLabelEditor(Self.peer)
model.clearLabel()
await waitUntil { !model.state.isSavingLabel }
XCTAssertEqual(gateway.setLabels.count, 1)
XCTAssertNil(gateway.setLabels[0].label)
}
func testBlankDraftClearsTheLabel() async {
gateway.savedDevices = [savedDevice(localLabel: "Old")]
let model = await makeModel()
model.openLabelEditor(Self.peer)
model.setLabelDraft(" ")
model.saveLabel()
await waitUntil { !model.state.isSavingLabel }
XCTAssertNil(gateway.setLabels[0].label)
}
// MARK: - Targeted offers
func testApprovingOfferStartsTheReceivePull() async {
gateway.offerResponseResult = .success(.approved(transferId: "t1"))
let model = await makeModel()
model.acceptTargetedOffer("t1")
await waitUntil { self.gateway.offerResponses.count == 1 && !model.state.isLoading }
XCTAssertEqual(gateway.offerResponses.map(\.accepted), [true])
XCTAssertEqual(gateway.targetedReceives.map(\.transferId), ["t1"])
}
func testDecliningOfferDoesNotPull() async {
gateway.offerResponseResult = .success(.declined)
let model = await makeModel()
model.declineTargetedOffer("t1")
await waitUntil { self.gateway.offerResponses.count == 1 }
XCTAssertEqual(gateway.offerResponses.map(\.accepted), [false])
XCTAssertTrue(gateway.targetedReceives.isEmpty)
}
func testAlreadySettledOfferDoesNotStartASecondPull() async {
// The idempotent replay path returns the existing result; re-pulling would
// duplicate work against a transfer that is already resolved.
gateway.offerResponseResult = .success(.alreadySettled(transferId: "t1"))
let model = await makeModel()
model.acceptTargetedOffer("t1")
await waitUntil { self.gateway.offerResponses.count == 1 }
XCTAssertTrue(gateway.targetedReceives.isEmpty)
}
func testConcurrentResponsesToTheSameOfferAreIgnored() async {
gateway.offerResponseResult = .success(.declined)
let model = await makeModel()
model.declineTargetedOffer("t1")
model.declineTargetedOffer("t1")
await waitUntil { !model.state.targetedOffers.respondingIds.contains("t1") }
XCTAssertEqual(gateway.offerResponses.count, 1)
}
// MARK: - Transfer lifecycle
func testResumeUsesTheResumePath() async {
let model = await makeModel()
model.resumeTargetedTransfer("t1")
await waitUntil { !model.state.busyTransferIds.contains("t1") }
XCTAssertEqual(gateway.targetedResumes.map(\.id), ["t1"])
XCTAssertTrue(gateway.targetedReceives.isEmpty)
}
func testCancelAndDeleteReachTheCore() async {
let model = await makeModel()
model.cancelTargetedTransfer("t1")
await waitUntil { !model.state.busyTransferIds.contains("t1") }
model.deleteTargetedTransfer("t2")
await waitUntil { !model.state.busyTransferIds.contains("t2") }
XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t1"])
XCTAssertEqual(gateway.deletedTargetedTransfers, ["t2"])
}
func testBusyTransferIgnoresRepeatedCommands() async {
let model = await makeModel()
model.cancelTargetedTransfer("t1")
model.cancelTargetedTransfer("t1")
await waitUntil { !model.state.busyTransferIds.contains("t1") }
XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t1"])
}
// MARK: - Destructive actions
func testForgetAndBlockReachTheCoreAndRefresh() async {
gateway.savedDevices = [savedDevice()]
let model = await makeModel()
model.forget(Self.peer)
await waitUntil { !model.state.busyPeerIds.contains(Self.peer) }
model.block(Self.peer)
await waitUntil { !model.state.busyPeerIds.contains(Self.peer) }
XCTAssertEqual(gateway.forgottenDevices, [Self.peer])
XCTAssertEqual(gateway.blockedCalls, [Self.peer])
}
func testBusyPeerIgnoresRepeatedCommands() async {
gateway.savedDevices = [savedDevice()]
let model = await makeModel()
model.forget(Self.peer)
model.forget(Self.peer)
await waitUntil { !model.state.busyPeerIds.contains(Self.peer) }
XCTAssertEqual(gateway.forgottenDevices, [Self.peer])
}
// MARK: - Targeted send
private func picked(_ name: String, isDirectory: Bool = false) -> PickedShareFile {
PickedShareFile(
value: "/tmp/\(name)", displayName: name, sizeBytes: 10,
isTemporaryCopy: true, isDirectory: isDirectory
)
}
func testSendRequiresDestinationSourcesAndName() async {
gateway.savedDevices = [savedDevice()]
let model = await makeModel()
XCTAssertFalse(model.state.canCreateTargetedTransfer)
model.beginSend(to: Self.peer)
XCTAssertFalse(model.state.canCreateTargetedTransfer, "no sources yet")
model.onSendFilesPicked([picked("a.txt")])
XCTAssertTrue(model.state.canCreateTargetedTransfer)
model.setSendTransferName(" ")
XCTAssertFalse(model.state.canCreateTargetedTransfer, "a blank name is not a name")
}
func testSendCreatesTargetedTransferAndClosesComposition() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer())
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt")])
model.createTargetedTransfer()
await waitUntil { !model.state.isCreatingSend }
XCTAssertEqual(gateway.createdTargetedTransfers.map(\.receiverEndpointId), [Self.peer])
XCTAssertEqual(gateway.createdTargetedTransfers.first?.transferName, "a.txt")
XCTAssertNil(model.state.sendTargetPeerId)
XCTAssertTrue(model.state.sendFiles.isEmpty)
// The core owns the bytes once the transfer exists; the picker copy goes.
XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"])
}
func testSendFailureKeepsCompositionForRetry() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .failure(TestError.unimplemented)
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt")])
model.setSendTransferName("Report")
model.createTargetedTransfer()
await waitUntil { !model.state.isCreatingSend }
// Retrying must not require re-picking the sources.
XCTAssertEqual(model.state.sendTargetPeerId, Self.peer)
XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/a.txt"])
XCTAssertEqual(model.state.sendTransferName, "Report")
XCTAssertTrue(fileSystem.discardedFiles.isEmpty, "sources must survive a failed create")
}
func testRemovingASourceRederivesOnlyAGeneratedName() async {
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt"), picked("b.txt")])
model.setSendTransferName("My Name")
model.removeSendFile("/tmp/b.txt")
XCTAssertEqual(model.state.sendTransferName, "My Name")
XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/a.txt"])
}
func testReplacingSelectionDiscardsThePreviousPickerCopies() async {
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt")])
model.onSendFilesPicked([picked("b.txt")])
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"])
XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/b.txt"])
}
func testCancellingSendDiscardsSources() async {
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt")])
model.cancelSend()
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
XCTAssertNil(model.state.sendTargetPeerId)
XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"])
}
func testFolderSourcesAreSupported() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer())
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("Photos", isDirectory: true)])
model.createTargetedTransfer()
await waitUntil { !model.state.isCreatingSend }
XCTAssertEqual(fileSystem.targetedSends.first?.files.first?.isDirectory, true)
}
// MARK: - Signals
func testPairingSignalTriggersRefresh() async {
let model = await makeModel()
gateway.savedDevices = [savedDevice()]
gateway.emit(.pairingChanged)
await waitUntil { model.state.savedDevices.count == 1 }
XCTAssertEqual(model.state.savedDevices.map(\.endpointId), [Self.peer])
}
func testInvitationSignalsDoNotTriggerRefresh() async {
let model = await makeModel()
gateway.savedDevices = [savedDevice()]
gateway.emit(.transfersChanged(transferId: 1))
gateway.emit(.approvalChanged(transferId: 1))
try? await Task.sleep(nanoseconds: 100_000_000)
// The saved-device domain is unaffected by invitation-share activity.
XCTAssertTrue(model.state.savedDevices.isEmpty)
}
}

View File

@@ -9,6 +9,9 @@ struct RootView: View {
@StateObject private var sendModel: SendModel
@StateObject private var receiveModel: ReceiveModel
@StateObject private var settingsModel: SettingsModel
@StateObject private var savedDevicesModel: SavedDevicesModel
/// Held so it stays alive for the app's lifetime; it has no view of its own.
@StateObject private var savedDeviceNotifications: SavedDeviceNotificationCoordinator
@Environment(\.scenePhase) private var scenePhase
@@ -44,6 +47,21 @@ struct RootView: View {
messages: graph.messages,
bugReports: NoopBugReportService()
))
let savedDevices = SavedDevicesModel(
repository: graph.coreRepository,
fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository,
messages: graph.messages
)
_savedDevicesModel = StateObject(wrappedValue: savedDevices)
// Reads the model's snapshot rather than the core directly, so it observes
// exactly the state the UI is showing.
_savedDeviceNotifications = StateObject(wrappedValue: SavedDeviceNotificationCoordinator(
model: savedDevices,
notifications: dependencies.notificationService,
visibility: graph.visibility,
messages: graph.messages
))
}
var body: some View {
@@ -52,6 +70,13 @@ struct RootView: View {
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
ZStack {
navigation(windowClass: windowClass)
// Hosted at the root so a pairing request or targeted offer is
// answerable from any tab, and suppressed while a transfer approval
// is up so two blocking decisions never stack.
.savedDevicePrompts(
model: savedDevicesModel,
suppressed: graph.approvalCoordinator.state.current != nil
)
// Observe the coordinator/messages from the *persisted* `graph`
// StateObject. Deriving them in `init` bound the view to a throwaway
// AppGraph rebuilt on every re-init, whose coordinator never receives
@@ -68,7 +93,11 @@ struct RootView: View {
// A small, unobtrusive indicator while the core finishes its async
// startup otherwise the lists look empty and the app feels stalled.
if !sendModel.coreState.isInitialized {
CoreStartingOverlay()
CoreStartingOverlay(
error: appModel.startupError,
detail: appModel.startupErrorDetail,
onRetry: appModel.retryStartup
)
}
}
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
@@ -167,6 +196,8 @@ struct RootView: View {
switch destination {
case .send: SendScreen(model: sendModel, windowClass: windowClass)
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
case .savedDevices:
SavedDevicesScreen(model: savedDevicesModel, windowClass: windowClass)
case .settings:
SettingsScreen(model: settingsModel, windowClass: windowClass)
}
@@ -253,21 +284,65 @@ private struct ApprovalLayer: View {
}
}
/// A full-window cover with a centered spinner shown while the core is starting.
/// A full-window cover shown while the core is starting or, when startup fails,
/// the reason and a retry. This overlay sits above the snackbar host, so a failure
/// reported only through a toast would be invisible behind it and the app would
/// look like it was loading forever.
private struct CoreStartingOverlay: View {
let error: UiText?
/// Debug builds only; nil in Release.
let detail: String?
let onRetry: () -> Void
var body: some View {
ZStack {
backgroundColor.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView().controlSize(.large)
Text(String(localized: L10n.App.starting))
.font(.headline)
.foregroundStyle(.secondary)
if let error {
VStack(spacing: 16) {
Image(systemSymbol: .exclamationmarkTriangleFill)
.font(.system(size: 34))
.foregroundStyle(.orange)
// Not "Starting": startup has stopped, and saying otherwise
// while showing an error contradicts itself.
Text(String(localized: L10n.Error.initialization))
.font(.headline)
.multilineTextAlignment(.center)
Text(error.resolved())
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.textSelection(.enabled)
.frame(maxWidth: 420)
if let detail {
ScrollView {
Text(detail)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
}
.frame(maxWidth: 420, maxHeight: 180)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
}
Button(String(localized: L10n.Button.retry), action: onRetry)
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding(32)
} else {
VStack(spacing: 16) {
ProgressView().controlSize(.large)
Text(String(localized: L10n.App.starting))
.font(.headline)
.foregroundStyle(.secondary)
}
}
}
.transition(.opacity)
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(String(localized: L10n.App.starting)))
.accessibilityLabel(Text(error?.resolved() ?? String(localized: L10n.App.starting)))
}
private var backgroundColor: Color {
@@ -284,10 +359,3 @@ import UIKit
#else
import AppKit
#endif
/// Hosts the device-history consent prompts, alongside `ApprovalLayer`.
///
/// Separate from the approval layer because the two never compete: an approval
/// belongs to a transfer this device is sending, and these belong to a device
/// asking to reach it. Both are suppressed while the other is up so the user is
/// never answering two modals at once.

View File

@@ -47,4 +47,51 @@ protocol CoreGateway: AnyObject {
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
func refresh() async -> Result<Void, Error>
// MARK: - Saved devices
func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error>
func declinePairingEligibility(peerEndpointId: String) async -> Result<Void, Error>
/// Asks a peer to pair. Returns false when no valid eligibility exists, which
/// the core rejects silently so a stranger cannot provoke a prompt.
func requestSavedDevicePairing(peerEndpointId: String) async -> Result<Bool, Error>
func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result<Bool, Error>
func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error>
func listSavedDevices() async -> Result<[SavedDeviceModel], Error>
/// Sets or clears (`nil`) the user-owned local label.
func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result<Void, Error>
func forgetSavedDevice(peerEndpointId: String) async -> Result<Void, Error>
func blockDevice(peerEndpointId: String) async -> Result<Void, Error>
/// Removes only the deny rule. Grants, relationships, and cancelled transfers
/// are not restored re-saving needs another qualifying transfer and consent.
func unblockDevice(peerEndpointId: String) async -> Result<Void, Error>
func listBlockedDevices() async -> Result<[String], Error>
// MARK: - Targeted transfers
func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error>
func respondToTargetedOffer(
transferId: String,
accepted: Bool
) async -> Result<TargetedOfferResponseModel, Error>
func createTargetedTransfer(
receiverEndpointId: String,
sources: [ShareSource],
transferName: String?
) async -> Result<TargetedTransferModel, Error>
func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error>
/// Pulls an approved transfer into a security-scoped destination, holding
/// access for the duration of the stream (mirrors the invitation receive path).
func receiveTargetedTransfer(
transferId: String,
outputDirectoryUrl: String
) async -> Result<Void, Error>
/// Resumes an interrupted transfer. The same immutable transfer continues from
/// its verified progress and is not re-approved.
func resumeTargetedTransfer(
id: String,
outputDirectoryUrl: String
) async -> Result<Void, Error>
func cancelTargetedTransfer(id: String) async -> Result<Void, Error>
func deleteTargetedTransfer(id: String) async -> Result<Void, Error>
}

View File

@@ -47,6 +47,12 @@ enum EventPhase: String, Equatable, Sendable {
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).
@@ -174,6 +180,13 @@ enum CoreSignal: Equatable, Sendable {
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)

View File

@@ -293,6 +293,140 @@ final class CoreRepository: ObservableObject, CoreGateway {
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) {
@@ -302,6 +436,21 @@ final class CoreRepository: ObservableObject, CoreGateway {
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))
@@ -497,6 +646,98 @@ private extension TransferMetadata {
}
}
// 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(

View File

@@ -43,6 +43,15 @@ protocol FileSystemService {
senderName: String,
destination: ShareDestination
) async -> Result<Share, Error>
/// Sends a picked selection straight to one saved device. Separate from
/// `sharePickedFiles` because a targeted transfer is its own domain with its
/// own result type it is not an access mode on an invitation share.
func sendPickedFilesToSavedDevice(
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
receiverEndpointId: String
) async -> Result<TargetedTransferModel, Error>
}
extension FileSystemService {

View File

@@ -0,0 +1,182 @@
import Foundation
/// App-facing saved-device domain models, ported from `core/SavedDeviceModels.kt`.
/// The repository maps the generated UniFFI records into these so the UI never
/// depends on the binding surface directly.
///
/// A saved device is a remote VniDrop *app-installation identity*, not a person,
/// account, or piece of hardware. Display names and platform hints are untrusted
/// peer-supplied hints and must never be used to merge or match identities.
struct SavedDeviceModel: Equatable, Identifiable, Sendable {
/// The remote iroh endpoint identity. Stable, cryptographic, and the only
/// safe way to identify a peer.
let endpointId: String
/// User-owned local label. Takes precedence over `remoteDisplayName`.
let localLabel: String?
/// Untrusted display-name hint supplied by the peer.
let remoteDisplayName: String?
let createdAt: Int64
let lastAuthenticatedAt: Int64?
var id: String { endpointId }
/// The name to show, or nil when neither side supplied one. Callers fall back
/// to `L10n.SavedDevices.unnamed` (see `SavedDeviceTransferHistory.kt`).
var displayNameOrNil: String? {
if let localLabel, !localLabel.trimmed.isEmpty { return localLabel }
if let remoteDisplayName, !remoteDisplayName.trimmed.isEmpty { return remoteDisplayName }
return nil
}
}
/// Durable consent lifecycle for one remote app-installation identity. A
/// relationship is usable only in `saved`; the pending states are bounded
/// operations that cannot initiate a transfer.
enum DeviceRelationshipStateModel: Equatable, Sendable {
case pendingOutgoing
case pendingIncoming
case saved
case revoked
case blocked
}
struct DeviceRelationshipModel: Equatable, Identifiable, Sendable {
let remoteEndpointId: String
let state: DeviceRelationshipStateModel
let generation: UInt64
let minimumProtocolVersion: UInt16
let createdAt: Int64
let updatedAt: Int64
var id: String { remoteEndpointId }
}
/// Single-use permission to *ask* to pair, created by a fully completed
/// authenticated transfer and expiring 24 hours later. Consumed by pairing,
/// declining, expiry, forget, block, or reset.
struct PairingEligibilityModel: Equatable, Identifiable, Sendable {
let peerEndpointId: String
/// Untrusted display-name hint from the qualifying transfer. Usually the only
/// name available for a peer that is not saved yet.
let remoteDisplayName: String?
let sessionId: String
let protocolVersion: UInt16
let createdAt: Int64
let expiresAt: Int64
var id: String { peerEndpointId }
}
/// A pre-approval targeted offer awaiting a local approve/decline. Lives only in
/// the core's bounded live-session queue a restart, timeout, disconnect, or
/// sender cancellation removes it, so it is never durable UI state.
struct PendingTargetedOfferModel: Equatable, Identifiable, Sendable {
let transferId: String
let senderEndpointId: String
let receiverEndpointId: String
let manifestId: String
let contentHash: String
/// Peer-supplied and untrusted; render it as text, never as a path.
let transferName: String
let fileCount: UInt64
let totalSize: UInt64
let protocolVersion: UInt16
let receivedAt: Int64
var id: String { transferId }
}
/// Durable targeted-transfer lifecycle. Rust validates every transition; the UI
/// only invokes typed operations and renders the snapshot it gets back.
enum TargetedTransferStateModel: Equatable, Sendable {
case preparing
case offering
case awaitingApproval
case approved
case connecting
case transferring
case interrupted
case completed
case declined
case cancelled
case failed
case deleted
}
struct TargetedTransferModel: Equatable, Identifiable, Sendable {
let id: String
let senderEndpointId: String
let receiverEndpointId: String
let manifestId: String
/// Peer-supplied and untrusted; render it as text, never as a path.
let transferName: String
let fileCount: UInt64
let totalSize: UInt64
/// Bytes verified so far; survives interruption for resume.
let verifiedBytes: UInt64
let state: TargetedTransferStateModel
let createdAt: Int64
let updatedAt: Int64
}
/// Outcome of responding to a targeted offer. `alreadySettled` is the idempotent
/// replay path the core returns the existing result rather than creating a
/// duplicate approval.
enum TargetedOfferResponseModel: Equatable, Sendable {
case approved(transferId: String)
case declined
case alreadySettled(transferId: String)
}
// MARK: - Lifecycle helpers
extension TargetedTransferStateModel {
/// States where bytes may still move, so progress is meaningful.
var isActive: Bool {
switch self {
case .connecting, .transferring: return true
default: return false
}
}
/// No further transition is possible without creating a new transfer.
var isTerminal: Bool {
switch self {
case .completed, .declined, .cancelled, .failed, .deleted: return true
default: return false
}
}
/// Cancellation withdraws the offer before approval and stops authorization
/// plus active streaming after it. Terminal transfers have nothing to stop.
var canCancel: Bool { !isTerminal }
/// An interrupted transfer keeps its verified progress and resumes the same
/// immutable transfer without asking for approval again.
var canResume: Bool { self == .interrupted }
/// The receiver pulls content once the sender's authorization is in place.
var canReceive: Bool { self == .approved }
/// Deletion makes authorization unusable and removes resumable state. Offered
/// on anything already terminal except an entry that is itself deleted.
var canDelete: Bool { isTerminal && self != .deleted }
}
extension TargetedTransferModel {
/// Fraction of verified payload in `0...1`, or nil when the total is unknown
/// or the state carries no meaningful progress.
var progressFraction: Double? {
guard totalSize > 0, state.isActive || state == .interrupted else { return nil }
return min(1, Double(verifiedBytes) / Double(totalSize))
}
}
extension PairingEligibilityModel {
func isExpired(now: Int64) -> Bool { now >= expiresAt }
}
private extension String {
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
}

View File

@@ -7,10 +7,19 @@ import Combine
final class AppModel: ObservableObject {
@Published private(set) var destination: AppDestination = .send
@Published private(set) var themeMode: ThemeMode = .system
/// Why startup failed, or nil while it is still in progress or has succeeded.
/// The startup overlay covers the snackbar, so without this a failed
/// `initialize` was indistinguishable from an app that never finished loading.
@Published private(set) var startupError: UiText?
/// Untranslated failure detail, kept for the debug overlay only. The friendly
/// message alone cannot distinguish a missing keychain item from a database
/// fault, which makes a startup failure undiagnosable on a real device.
@Published private(set) var startupErrorDetail: String?
private let environment: PlatformEnvironment
private let repository: CoreGateway
private let messages: UiMessageController
private let relayConfiguration: RelayConfiguration
private var cancellables = Set<AnyCancellable>()
init(
@@ -22,16 +31,11 @@ final class AppModel: ObservableObject {
self.environment = environment
self.repository = repository
self.messages = messages
self.relayConfiguration = preferences.preferences.relayConfiguration
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
Task {
let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: preferences.preferences.relayConfiguration
)
if case .failure(let error) = result { messages.error(error) }
}
Task { await initializeCore() }
preferences.$preferences
.map(\.themeMode)
@@ -40,6 +44,30 @@ final class AppModel: ObservableObject {
.store(in: &cancellables)
}
/// Runs core startup, keeping the failure reason for the overlay to show.
/// Also logged, because a user-facing message alone is not diagnosable.
func initializeCore() async {
startupError = nil
startupErrorDetail = nil
let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: relayConfiguration
)
if case .failure(let error) = result {
AppLogger.error("lifecycle", "core initialization failed", error)
startupError = error.toUiText()
#if DEBUG
startupErrorDetail = error.technicalDetail
#endif
messages.error(error)
}
}
func retryStartup() {
guard startupError != nil else { return }
Task { await initializeCore() }
}
func selectDestination(_ destination: AppDestination) {
guard destination != self.destination else { return }
self.destination = destination

View File

@@ -0,0 +1,264 @@
import Combine
import Foundation
/// A saved-device moment worth a local notification.
enum SavedDeviceNotificationKind: Equatable {
/// A peer asked to pair and is waiting on an answer.
case pairingRequest
/// A targeted offer is waiting on approve/decline.
case targetedOffer
/// An incoming targeted transfer finished downloading here.
case targetedReceiveCompleted
/// An incoming targeted transfer failed here.
case targetedReceiveFailed
/// A transfer we sent finished downloading on the peer.
case targetedSendCompleted
/// A transfer we sent failed on the peer.
case targetedSendFailed
}
extension SavedDeviceNotificationKind {
/// Stable id component. Distinct per kind so a transfer cannot collide with
/// itself across directions.
var idPrefix: String {
switch self {
case .pairingRequest: return "pairing-request"
case .targetedOffer: return "offer"
case .targetedReceiveCompleted: return "receive-completed"
case .targetedReceiveFailed: return "receive-failed"
case .targetedSendCompleted: return "send-completed"
case .targetedSendFailed: return "send-failed"
}
}
}
/// A saved-device notification resolved from model state but not yet published.
struct PlannedSavedDeviceNotification: Equatable {
let id: String
let kind: SavedDeviceNotificationKind
/// Peer display name, already resolved; nil when we have no trustworthy one.
let deviceName: String?
let transferName: String?
}
/// Pure: notifications for consent decisions currently waiting on the user.
/// These are *pending* moments they are withdrawn when the underlying request
/// disappears, so the caller cancels ids that stop appearing here.
func plannedSavedDevicePrompts(_ state: SavedDevicesState) -> [PlannedSavedDeviceNotification] {
var planned: [PlannedSavedDeviceNotification] = []
for relationship in state.pendingRelationships where relationship.state == .pendingIncoming {
planned.append(PlannedSavedDeviceNotification(
id: "pairing-request-\(relationship.remoteEndpointId)",
kind: .pairingRequest,
deviceName: state.eligibilities
.first { $0.peerEndpointId == relationship.remoteEndpointId }?
.remoteDisplayName
?? state.device(relationship.remoteEndpointId)?.displayNameOrNil,
transferName: nil
))
}
for offer in state.targetedOffers.pending {
planned.append(PlannedSavedDeviceNotification(
id: "targeted-offer-\(offer.transferId)",
kind: .targetedOffer,
// Only a saved sender has a name we can vouch for.
deviceName: state.targetedOffers.senderDisplayNames[offer.senderEndpointId],
transferName: offer.transferName
))
}
return planned
}
/// Pure: notifications for targeted transfers that reached a terminal outcome,
/// excluding already-published ids. Terminal moments notify at most once.
func plannedTargetedOutcomes(
_ transfers: [SavedDeviceTransferItem],
published: Set<String>
) -> [PlannedSavedDeviceNotification] {
transfers.compactMap { transfer in
// Direction decides the wording: on the receiving device the transfer
// "finished downloading", while on the sending device it is the *peer*
// that finished. Ignoring direction told the sender it had downloaded
// its own outgoing files.
let kind: SavedDeviceNotificationKind
switch (transfer.direction, transfer.state) {
case (.incoming, .completed): kind = .targetedReceiveCompleted
case (.incoming, .failed): kind = .targetedReceiveFailed
case (.outgoing, .completed): kind = .targetedSendCompleted
case (.outgoing, .failed): kind = .targetedSendFailed
// Cancelled and declined are the user's own doing on one side or the
// other; notifying about them would be noise.
default: return nil
}
let id = "targeted-\(kind.idPrefix)-\(transfer.id)"
guard !published.contains(id) else { return nil }
return PlannedSavedDeviceNotification(
id: id, kind: kind,
deviceName: transfer.peerDisplayName,
transferName: transfer.transferName
)
}
}
/// Fires local notifications for the saved-device domain: pairing requests and
/// targeted offers waiting on a decision, and targeted transfers reaching a
/// terminal outcome. Invitation-share moments belong to
/// `TransferNotificationCoordinator`; approval prompts to `ApprovalCoordinator`.
///
/// Reads `SavedDevicesModel`'s published snapshot rather than querying the core
/// again the model already refreshes on every saved-device signal, and a second
/// read loop would race it.
@MainActor
final class SavedDeviceNotificationCoordinator: ObservableObject {
private let notifications: LocalNotificationService
private let visibility: AppVisibility
private let messages: UiMessageController
/// Prompt ids currently published, so they can be withdrawn when answered.
private var publishedPrompts = Set<String>()
/// Terminal ids already seen; never re-published.
private var publishedOutcomes = Set<String>()
private var primedOutcomes = false
private var cancellables = Set<AnyCancellable>()
init(
model: SavedDevicesModel,
notifications: LocalNotificationService,
visibility: AppVisibility,
messages: UiMessageController
) {
self.notifications = notifications
self.visibility = visibility
self.messages = messages
// Recompute when any input changes: the snapshot itself, foregrounding, or
// the permission being granted later.
Publishers.CombineLatest3(
model.$state,
visibility.$isForeground,
notifications.$permission
)
.sink { [weak self] state, _, _ in
guard let self else { return }
Task { await self.synchronize(state) }
}
.store(in: &cancellables)
}
/// iOS suppresses notifications while the user is in the app (the modal or the
/// screen shows instead); macOS presents them even when active, since the app
/// window is usually open. Mirrors the other two coordinators.
private var canPublish: Bool {
guard notifications.permission == .granted else { return false }
#if os(iOS)
return !visibility.isForeground
#else
return true
#endif
}
private func synchronize(_ state: SavedDevicesState) async {
await synchronizePrompts(state)
await synchronizeOutcomes(state)
}
private func synchronizePrompts(_ state: SavedDevicesState) async {
let planned = plannedSavedDevicePrompts(state)
let plannedIds = Set(planned.map(\.id))
// Withdraw notifications whose request is gone answered here, answered on
// the peer, expired, or dropped when the core restarted. Leaving them would
// invite the user to act on a decision that no longer exists.
for id in publishedPrompts.subtracting(plannedIds) {
notifications.cancel(id: id)
publishedPrompts.remove(id)
}
guard canPublish else {
// Suppressed: withdraw anything already showing, but keep the request
// itself pending so it can notify again later.
for id in publishedPrompts { notifications.cancel(id: id) }
publishedPrompts.removeAll()
return
}
for plan in planned where !publishedPrompts.contains(plan.id) {
// Reserve the id *before* awaiting: CombineLatest can fire repeatedly in
// quick succession, and a repeated add of an in-flight identifier is
// coalesced into a silent update with no banner.
publishedPrompts.insert(plan.id)
if case .failure(let error) = await publish(plan) {
publishedPrompts.remove(plan.id)
messages.error(error)
}
}
}
private func synchronizeOutcomes(_ state: SavedDevicesState) async {
let planned = plannedTargetedOutcomes(state.targetedTransfers, published: publishedOutcomes)
guard primedOutcomes else {
// The first snapshot carries existing history; mark those terminal
// transfers seen without notifying so only new transitions notify.
primedOutcomes = true
for plan in planned { publishedOutcomes.insert(plan.id) }
return
}
for plan in planned {
// Mark seen unconditionally a terminal moment notifies at most once,
// whether or not the gate let it through.
publishedOutcomes.insert(plan.id)
guard canPublish else { continue }
if case .failure(let error) = await publish(plan) {
messages.error(error)
}
}
}
private func publish(_ plan: PlannedSavedDeviceNotification) async -> Result<Void, Error> {
let device = plan.deviceName ?? String(localized: L10n.Approval.nearbyDevice)
let transferName = plan.transferName ?? String(localized: L10n.Receive.unknownTransfer)
let notification: LocalNotification
switch plan.kind {
case .pairingRequest:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Pairing.requestTitle),
body: L10n.Pairing.requestBody(device: device)
)
case .targetedOffer:
// The sender is offering to send to us not the invitation-approval
// case, where the remote device asks to receive from us.
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Targeted.offerTitle),
body: L10n.Targeted.offerBody(device: device, transferName: transferName)
)
case .targetedReceiveCompleted:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiveCompletedTitle),
body: L10n.Notifications.receiveCompletedBody(transferName: transferName)
)
case .targetedReceiveFailed:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiveFailedTitle),
body: L10n.Notifications.receiveFailedBody(transferName: transferName)
)
case .targetedSendCompleted:
// We are the sender: the *peer* finished downloading, not us.
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiverCompletedTitle),
body: L10n.Notifications.receiverCompletedBody(receiver: device, transferName: transferName)
)
case .targetedSendFailed:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiverFailedTitle),
body: L10n.Notifications.receiverFailedBody(receiver: device, transferName: transferName)
)
}
return await notifications.publish(notification)
}
}

View File

@@ -113,6 +113,10 @@ final class TransferNotificationCoordinator: ObservableObject {
Task { await self.syncReceivers(transferId: transferId) }
case .approvalChanged:
break
case .pairingChanged, .targetedTransferChanged:
// Saved-device notifications are owned by the saved-device
// coordinator, which tracks its own notification identifiers.
break
}
}
.store(in: &cancellables)

View File

@@ -0,0 +1,568 @@
import SwiftUI
import SFSafeSymbols
/// Per-device details: Send, label/forget/block, and this device's targeted
/// transfers with their lifecycle actions. Presented as a sheet on compact
/// layouts and as a native inspector on macOS.
struct SavedDeviceDetailsView: View {
@ObservedObject var model: SavedDevicesModel
let peerEndpointId: String
let windowClass: WindowClass
let onClose: () -> Void
/// Which destructive action is awaiting confirmation.
@State private var confirming: DestructiveAction?
private enum DestructiveAction: String, Identifiable {
case forget
case block
var id: String { rawValue }
}
private var state: SavedDevicesState { model.state }
private var device: SavedDeviceModel? { state.device(peerEndpointId) }
private var busy: Bool { state.busyPeerIds.contains(peerEndpointId) }
private var transfers: [SavedDeviceTransferItem] { state.transfers(for: peerEndpointId) }
var body: some View {
Group {
if let device {
content(device)
} else {
// The device can disappear underneath us forget, block, or a peer
// reinstall all remove it. Close rather than render a stale identity.
Color.clear.onAppear(perform: onClose)
}
}
.confirmationDialog(
Text(String(localized: confirming == .block
? L10n.Saved.devicesBlockConfirmTitle
: L10n.Saved.devicesForgetConfirmTitle)),
isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }),
titleVisibility: .visible
) {
Button(String(localized: L10n.Button.cancel), role: .cancel) { confirming = nil }
Button(
String(localized: confirming == .block
? L10n.Saved.devicesBlockAction
: L10n.Saved.devicesForgetAction),
role: .destructive
) {
let action = confirming
confirming = nil
switch action {
case .forget: model.forget(peerEndpointId)
case .block: model.block(peerEndpointId)
case nil: break
}
}
} message: {
let name = device?.displayName ?? String(localized: L10n.Saved.devicesUnnamed)
Text(confirming == .block
? L10n.Saved.devicesBlockConfirmBody(device: name)
: L10n.Saved.devicesForgetConfirmBody(device: name))
}
.sheet(isPresented: Binding(
get: { state.labelingPeerId == peerEndpointId },
set: { if !$0 { model.dismissLabelEditor() } }
)) {
LabelEditorSheet(model: model, windowClass: windowClass)
}
.sheet(isPresented: Binding(
get: { state.sendTargetPeerId == peerEndpointId },
set: { if !$0 { model.cancelSend() } }
)) {
TargetedSendSheet(
model: model,
windowClass: windowClass,
deviceName: device?.displayName ?? String(localized: L10n.Saved.devicesUnnamed)
)
}
}
@ViewBuilder
private func content(_ device: SavedDeviceModel) -> some View {
List {
Section { DeviceHeader(device: device) }
Section {
Button {
model.beginSend(to: peerEndpointId)
} label: {
ActionRow(
symbol: .paperplaneFill,
title: String(localized: L10n.Saved.devicesSendAction),
tint: VniDropColors.brandPurple
)
}
.disabled(busy)
Button {
model.openLabelEditor(peerEndpointId)
} label: {
ActionRow(
symbol: .pencil,
title: String(localized: L10n.Saved.devicesLabelAction),
// Showing the current label makes it clear this renames
// locally rather than changing what the peer calls itself.
detail: device.localLabel?.isEmpty == false ? device.localLabel : nil
)
}
.disabled(busy)
}
Section {
if transfers.isEmpty {
Text(String(localized: L10n.Saved.devicesTransferEmpty))
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.vertical, 2)
} else {
ForEach(transfers) { transfer in
TargetedTransferRow(
transfer: transfer,
busy: state.busyTransferIds.contains(transfer.id),
onReceive: { model.receiveTargetedTransfer(transfer.id) },
onResume: { model.resumeTargetedTransfer(transfer.id) },
onCancel: { model.cancelTargetedTransfer(transfer.id) },
onDelete: { model.deleteTargetedTransfer(transfer.id) }
)
}
}
} header: {
Text(String(localized: L10n.Saved.devicesTransfersTitle))
}
Section {
Button(role: .destructive) {
confirming = .forget
} label: {
ActionRow(
symbol: .trash,
title: String(localized: L10n.Saved.devicesForgetAction),
tint: .red
)
}
.disabled(busy)
Button(role: .destructive) {
confirming = .block
} label: {
ActionRow(
symbol: .nosign,
title: String(localized: L10n.Saved.devicesBlockAction),
tint: .red
)
}
.disabled(busy)
} header: {
Text(L10n.Saved.devicesMoreActions(device: device.displayName))
}
}
#if os(macOS)
.listStyle(.inset)
#else
.listStyle(.insetGrouped)
#endif
.buttonStyle(.plain)
.overlay(alignment: .top) {
if busy { ProgressView().controlSize(.small).padding(.top, 6) }
}
}
}
// MARK: - Pieces
private struct DeviceHeader: View {
let device: SavedDeviceModel
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 14) {
DeviceAvatar(size: 52)
VStack(alignment: .leading, spacing: 3) {
Text(device.displayName)
.font(.title3)
.fontWeight(.semibold)
.lineLimit(2)
// The authenticated peer-supplied name, shown only when a local
// label overrides it, so the user can tell the two apart.
if device.localLabel?.isEmpty == false, let remote = device.remoteDisplayName {
Text(L10n.Saved.devicesAuthenticatedName(name: remote))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
EndpointIdLabel(endpointId: device.endpointId)
}
.padding(.vertical, 6)
}
}
/// A tappable row that reads as a native list action rather than bare tinted text.
private struct ActionRow: View {
let symbol: SFSymbol
let title: String
var detail: String? = nil
var tint: Color = .primary
var body: some View {
HStack(spacing: 12) {
Image(systemSymbol: symbol)
.font(.system(size: 15))
.foregroundStyle(tint == .primary ? AnyShapeStyle(.secondary) : AnyShapeStyle(tint))
.frame(width: 22)
Text(title).foregroundStyle(tint)
Spacer(minLength: 8)
if let detail {
Text(detail)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.contentShape(Rectangle())
.padding(.vertical, 2)
}
}
/// One targeted transfer. The action its state actually calls for is a visible
/// button; everything else lives behind an overflow menu so a list of transfers
/// does not turn into a wall of buttons.
private struct TargetedTransferRow: View {
let transfer: SavedDeviceTransferItem
let busy: Bool
let onReceive: () -> Void
let onResume: () -> Void
let onCancel: () -> Void
let onDelete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemSymbol: transfer.direction == .outgoing ? .arrowUpCircle : .arrowDownCircle)
.font(.callout)
.foregroundStyle(.secondary)
Text(name)
.font(.body)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 8)
if busy {
ProgressView().controlSize(.small)
} else {
StatusPill(label: transfer.state.label, tone: transfer.state.tone)
}
}
HStack(spacing: 8) {
Text(L10n.Saved.devicesTransferFiles(
count: "\(transfer.fileCount)",
size: formatBytes(transfer.totalSize)
))
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
if let primary = primaryAction {
Button(String(localized: primary.title), action: primary.run)
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(busy)
}
if !overflowActions.isEmpty {
Menu {
ForEach(overflowActions, id: \.id) { action in
Button(String(localized: action.title), role: .destructive, action: action.run)
}
} label: {
Image(systemSymbol: .ellipsisCircle)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.disabled(busy)
}
}
if let progress = transfer.progressFraction {
ProgressView(value: progress)
Text(L10n.Saved.devicesTransferProgress(
verified: formatBytes(transfer.verifiedBytes),
total: formatBytes(transfer.totalSize)
))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private var name: String {
transfer.transferName.isEmpty
? String(localized: L10n.Saved.devicesUnnamed)
: transfer.transferName
}
private struct TransferAction {
let id: String
let title: String.LocalizationValue
let run: () -> Void
}
/// At most one of receive/resume applies, and only in one state each.
private var primaryAction: TransferAction? {
if transfer.state.canReceive {
return TransferAction(id: "receive", title: L10n.Saved.devicesTransferReceive, run: onReceive)
}
if transfer.state.canResume {
return TransferAction(id: "resume", title: L10n.Saved.devicesTransferResume, run: onResume)
}
return nil
}
private var overflowActions: [TransferAction] {
var actions: [TransferAction] = []
if transfer.state.canCancel {
actions.append(TransferAction(id: "cancel", title: L10n.Saved.devicesTransferCancel, run: onCancel))
}
if transfer.state.canDelete {
actions.append(TransferAction(id: "delete", title: L10n.Saved.devicesTransferDelete, run: onDelete))
}
return actions
}
}
// MARK: - Sheets
/// Label editor. Stays open and keeps its draft when the write fails, and blocks
/// its own dismissal while saving, so the retry path always survives.
///
/// Split per platform on purpose: macOS gets a compact dialog with one trailing
/// button row, iOS gets the native Cancel/Save toolbar. A single shared layout
/// produced two competing button rows and a lot of dead space.
private struct LabelEditorSheet: View {
@ObservedObject var model: SavedDevicesModel
let windowClass: WindowClass
private var isSaving: Bool { model.state.isSavingLabel }
/// The peer's own authenticated name, shown so it is obvious the label is a
/// local override rather than a rename on the other device.
private var remoteName: String? {
guard let peerId = model.state.labelingPeerId else { return nil }
guard let remote = model.state.device(peerId)?.remoteDisplayName, !remote.isEmpty else {
return nil
}
return remote
}
private var field: some View {
TextField(
String(localized: L10n.Saved.devicesLabelPlaceholder),
text: Binding(
get: { model.state.labelDraft },
set: { model.setLabelDraft($0) }
)
)
.textFieldStyle(.roundedBorder)
.labelsHidden()
.disabled(isSaving)
.onSubmit { if model.state.canSaveLabel { model.saveLabel() } }
}
var body: some View {
#if os(macOS)
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text(String(localized: L10n.Saved.devicesLabelTitle))
.font(.headline)
if let remoteName {
Text(L10n.Saved.devicesAuthenticatedName(name: remoteName))
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
field
HStack(spacing: 10) {
Button(String(localized: L10n.Saved.devicesLabelClear), role: .destructive) {
model.clearLabel()
}
.buttonStyle(.link)
// Nothing to clear when the device has no label yet.
.disabled(isSaving || !model.state.hasExistingLabel)
Spacer(minLength: 12)
if isSaving { ProgressView().controlSize(.small) }
Button(String(localized: L10n.Button.cancel), action: model.dismissLabelEditor)
.keyboardShortcut(.cancelAction)
.disabled(isSaving)
Button(String(localized: L10n.Saved.devicesLabelSave), action: model.saveLabel)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(isSaving || !model.state.canSaveLabel)
}
}
.padding(20)
.frame(width: 420)
.interactiveDismissDisabled(isSaving)
#else
NavigationStack {
Form {
Section {
field
} footer: {
if let remoteName {
Text(L10n.Saved.devicesAuthenticatedName(name: remoteName))
}
}
Section {
Button(String(localized: L10n.Saved.devicesLabelClear), role: .destructive) {
model.clearLabel()
}
.disabled(isSaving || !model.state.hasExistingLabel)
}
}
.navigationTitle(Text(String(localized: L10n.Saved.devicesLabelTitle)))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: L10n.Button.cancel), action: model.dismissLabelEditor)
.disabled(isSaving)
}
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView().controlSize(.small)
} else {
Button(String(localized: L10n.Saved.devicesLabelSave), action: model.saveLabel)
.disabled(!model.state.canSaveLabel)
}
}
}
}
.sheetSize(windowClass: windowClass, minWidth: 380, minHeight: 240)
.interactiveDismissDisabled(isSaving)
#endif
}
}
/// Hosts the targeted-send composer.
private struct TargetedSendSheet: View {
@ObservedObject var model: SavedDevicesModel
let windowClass: WindowClass
let deviceName: String
var body: some View {
NavigationStack {
ScrollView {
TargetedSendComposer(model: model, deviceName: deviceName)
}
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: L10n.Button.close), action: model.cancelSend)
.disabled(model.state.isCreatingSend)
}
}
}
// A create in flight owns the picked sources; discarding them mid-call
// would pull the files out from under the core's import.
.interactiveDismissDisabled(model.state.isCreatingSend)
.sheetSize(windowClass: windowClass, minWidth: 460, minHeight: 480)
}
}
private extension View {
/// Sizes a sheet for its host. A phone must never get a `minWidth` forcing
/// one wider than the screen pushes the content out of bounds instead of
/// growing the sheet, which is exactly what a fixed 460pt did.
@ViewBuilder
func sheetSize(windowClass: WindowClass, minWidth: CGFloat, minHeight: CGFloat) -> some View {
if windowClass == .phone {
self
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
frame(minWidth: minWidth, minHeight: minHeight)
}
}
}
// MARK: - Adaptive presentation
extension View {
/// Presents the details surface the way each platform expects: a sheet with
/// detents on compact layouts, a native inspector on macOS.
func savedDeviceDetails(
model: SavedDevicesModel,
windowClass: WindowClass,
selectedPeerId: Binding<String?>
) -> some View {
modifier(SavedDeviceDetailsPresentation(
model: model, windowClass: windowClass, selectedPeerId: selectedPeerId
))
}
}
private struct SavedDeviceDetailsPresentation: ViewModifier {
@ObservedObject var model: SavedDevicesModel
let windowClass: WindowClass
@Binding var selectedPeerId: String?
func body(content: Content) -> some View {
#if os(macOS)
content.inspector(isPresented: Binding(
get: { selectedPeerId != nil },
set: { if !$0 { selectedPeerId = nil } }
)) {
detail
.inspectorColumnWidth(min: 300, ideal: 360, max: 480)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
selectedPeerId = nil
} label: {
Label(String(localized: L10n.Button.close), systemSymbol: .sidebarRight)
}
}
}
}
#else
content.sheet(isPresented: Binding(
get: { selectedPeerId != nil },
set: { if !$0 { selectedPeerId = nil } }
)) {
NavigationStack {
detail
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: L10n.Button.close)) { selectedPeerId = nil }
}
}
}
.sheetSize(windowClass: windowClass, minWidth: 460, minHeight: 520)
}
#endif
}
@ViewBuilder
private var detail: some View {
if let peerId = selectedPeerId {
SavedDeviceDetailsView(
model: model,
peerEndpointId: peerId,
windowClass: windowClass,
onClose: { selectedPeerId = nil }
)
}
}
}

View File

@@ -0,0 +1,79 @@
import Foundation
/// Presentation-level saved-device models, ported from
/// `feature/saveddevices/SavedDeviceExperienceModels.kt`.
/// The one consent question currently worth asking. Only one is shown at a time:
/// an incoming request always outranks an eligibility we could act on ourselves.
enum PairingPrompt: Equatable, Identifiable {
/// We completed a qualifying transfer and may ask this peer to pair.
case eligibility(peerEndpointId: String, remoteDisplayName: String?)
/// This peer asked us; we approve or decline.
case incomingRequest(peerEndpointId: String, remoteDisplayName: String?)
var peerEndpointId: String {
switch self {
case .eligibility(let id, _), .incomingRequest(let id, _): return id
}
}
var remoteDisplayName: String? {
switch self {
case .eligibility(_, let name), .incomingRequest(_, let name): return name
}
}
var id: String {
switch self {
case .eligibility(let id, _): return "eligibility-\(id)"
case .incomingRequest(let id, _): return "incoming-\(id)"
}
}
}
struct PairingPromptState: Equatable {
var prompt: PairingPrompt?
var busy = false
}
struct TargetedOfferState: Equatable {
var pending: [PendingTargetedOfferModel] = []
/// Display names for senders we already have saved. A sender we have not
/// saved has no trustworthy name, so the UI falls back to a generic label
/// rather than rendering a peer-supplied string as if it were verified.
var senderDisplayNames: [String: String] = [:]
var respondingIds: Set<String> = []
var current: PendingTargetedOfferModel? { pending.first }
var currentSenderDisplayName: String? {
guard let current else { return nil }
return senderDisplayNames[current.senderEndpointId]
}
}
enum SavedDeviceTransferDirection: Equatable, Sendable {
case outgoing
case incoming
}
/// One targeted transfer as the details surface renders it: resolved against the
/// local endpoint so "peer" means the *other* device, whichever side we are on.
struct SavedDeviceTransferItem: Equatable, Identifiable, Sendable {
let id: String
let peerEndpointId: String
let peerDisplayName: String?
let direction: SavedDeviceTransferDirection
let transferName: String
let fileCount: UInt64
let totalSize: UInt64
let verifiedBytes: UInt64
let state: TargetedTransferStateModel
let createdAt: Int64
let updatedAt: Int64
var progressFraction: Double? {
guard totalSize > 0, state.isActive || state == .interrupted else { return nil }
return min(1, Double(verifiedBytes) / Double(totalSize))
}
}

View File

@@ -0,0 +1,75 @@
import SwiftUI
import SFSafeSymbols
/// Shared presentation helpers for the saved-device surfaces.
extension SavedDeviceModel {
/// `localLabel`, else the peer's untrusted display-name hint, else a generic
/// placeholder. Never falls back to the endpoint ID, which stays diagnostic.
var displayName: String {
displayNameOrNil ?? String(localized: L10n.Saved.devicesUnnamed)
}
}
extension TargetedTransferStateModel {
var label: String {
switch self {
case .preparing: return String(localized: L10n.Status.preparing)
case .offering: return String(localized: L10n.Status.offering)
case .awaitingApproval: return String(localized: L10n.Status.awaitingApproval)
case .approved: return String(localized: L10n.Status.approved)
case .connecting: return String(localized: L10n.Status.connecting)
case .transferring: return String(localized: L10n.Status.transferring)
case .interrupted: return String(localized: L10n.Status.interrupted)
case .completed: return String(localized: L10n.Status.completed)
case .declined: return String(localized: L10n.Status.declined)
case .cancelled: return String(localized: L10n.Status.cancelled)
case .failed: return String(localized: L10n.Status.failed)
// Deleted transfers are filtered out of the snapshot; label it defensively
// rather than crashing if one ever reaches the UI.
case .deleted: return String(localized: L10n.Status.cancelled)
}
}
var tone: PillTone {
switch self {
case .completed: return .success
case .failed, .declined: return .destructive
case .interrupted: return .warning
case .transferring, .connecting, .approved: return .brand
default: return .neutral
}
}
}
/// A device identity avatar. Deliberately generic: the core exposes no trustworthy
/// hardware type, so showing a specific device silhouette would imply knowledge
/// VniDrop does not have.
struct DeviceAvatar: View {
var symbol: SFSymbol = .laptopcomputerAndIphone
var tint: Color = .secondary
var size: CGFloat = 40
var body: some View {
Image(systemSymbol: symbol)
.font(.system(size: size * 0.45))
.foregroundStyle(tint)
.frame(width: size, height: size)
.background(.quaternary, in: RoundedRectangle(cornerRadius: size * 0.225))
}
}
/// The endpoint ID, shown as secondary diagnostic detail. Truncated in the middle
/// so both ends stay recognizable, and never used as a device's name.
struct EndpointIdLabel: View {
let endpointId: String
var body: some View {
Text(L10n.Saved.devicesEndpoint(deviceId: endpointId))
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
}
}

View File

@@ -0,0 +1,127 @@
import SwiftUI
import SFSafeSymbols
/// Consent prompts for the saved-device domain: the pairing question and the
/// targeted-offer approval. Both are blocking decisions, so each is a native
/// alert rather than an inline banner that could be scrolled past.
///
/// These live outside Experimental Settings saving a device and approving a
/// targeted transfer are top-level product decisions.
extension View {
/// Hosts both saved-device consent prompts. `suppressed` is set while the
/// transfer-approval modal is up, so the user is never answering two blocking
/// decisions at once the approval belongs to a transfer this device is
/// sending, these belong to a device asking to reach it.
func savedDevicePrompts(model: SavedDevicesModel, suppressed: Bool) -> some View {
modifier(PairingPromptHost(model: model, suppressed: suppressed))
.modifier(TargetedOfferPromptHost(model: model, suppressed: suppressed))
}
}
private struct PairingPromptHost: ViewModifier {
@ObservedObject var model: SavedDevicesModel
let suppressed: Bool
private var prompt: PairingPrompt? {
suppressed ? nil : model.state.pairingPrompt.prompt
}
func body(content: Content) -> some View {
content.alert(
Text(String(localized: title)),
isPresented: Binding(
get: { prompt != nil },
// A swipe/escape dismissal is not an answer: suppress locally
// without consuming the core's single-use eligibility. The
// `suppressed` guard matters hiding the alert for the approval
// modal also fires this setter, and must not count as a dismissal.
set: { if !$0, !suppressed { model.dismissPairingPrompt() } }
),
presenting: prompt
) { prompt in
Button(String(localized: acceptLabel(prompt)), action: model.acceptPairingPrompt)
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: model.declinePairingPrompt)
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissPairingPrompt)
} message: { prompt in
messageText(prompt)
}
}
private var title: String.LocalizationValue {
switch prompt {
case .incomingRequest: return L10n.Pairing.requestTitle
case .eligibility, nil: return L10n.Pairing.allowTitle
}
}
/// The incoming-request copy names the asking device; the eligibility copy is
/// a fixed explanation of what remembering allows.
@ViewBuilder
private func messageText(_ prompt: PairingPrompt) -> some View {
switch prompt {
case .incomingRequest:
Text(L10n.Pairing.requestBody(device: deviceName(prompt)))
case .eligibility:
Text(String(localized: L10n.Pairing.allowBody))
}
}
private func acceptLabel(_ prompt: PairingPrompt) -> String.LocalizationValue {
switch prompt {
case .incomingRequest: return L10n.Pairing.accept
case .eligibility: return L10n.Saved.devicesRememberAction
}
}
/// Falls back to a neutral placeholder: an unsaved peer's name is an untrusted
/// hint, and there may be none at all.
private func deviceName(_ prompt: PairingPrompt) -> String {
prompt.remoteDisplayName ?? String(localized: L10n.Saved.devicesUnnamed)
}
}
private struct TargetedOfferPromptHost: ViewModifier {
@ObservedObject var model: SavedDevicesModel
let suppressed: Bool
private var offer: PendingTargetedOfferModel? {
suppressed ? nil : model.state.targetedOffers.current
}
func body(content: Content) -> some View {
content.alert(
// Not the invitation-approval copy: there the remote device asks to
// *receive* from us, here it is offering to *send* to us.
Text(String(localized: L10n.Targeted.offerTitle)),
isPresented: Binding(
get: { offer != nil },
// Dismissal declines: an unanswered offer would otherwise hold a
// slot in the core's bounded per-sender queue. Never while
// `suppressed`, though being hidden behind the approval modal
// must not silently decline the sender.
set: { if !$0, !suppressed, let offer { model.declineTargetedOffer(offer.transferId) } }
),
presenting: offer
) { offer in
Button(String(localized: L10n.Button.approve)) {
model.acceptTargetedOffer(offer.transferId)
}
Button(String(localized: L10n.Button.refuse), role: .destructive) {
model.declineTargetedOffer(offer.transferId)
}
} message: { offer in
Text(L10n.Targeted.offerBody(
device: senderName,
transferName: offer.transferName
))
}
}
/// Only a *saved* sender has a name we can vouch for; anything else stays
/// generic rather than rendering a peer-supplied string as verified.
private var senderName: String {
model.state.targetedOffers.currentSenderDisplayName
?? String(localized: L10n.Approval.nearbyDevice)
}
}

View File

@@ -0,0 +1,618 @@
import Foundation
import Combine
/// Saved-device feature state, ported from `SavedDevicesViewModel.kt`
/// (`SavedDevicesState`). One snapshot drives the list, the details surface, the
/// pairing prompt, and the targeted-offer modal.
struct SavedDevicesState: Equatable {
var isLoading = true
var loadFailed = false
var eligibilities: [PairingEligibilityModel] = []
/// Relationships still awaiting consent on one side or the other. Rendered on
/// the main screen alongside saved devices; never usable for a transfer.
var pendingRelationships: [DeviceRelationshipModel] = []
var savedDevices: [SavedDeviceModel] = []
var targetedTransfers: [SavedDeviceTransferItem] = []
var pairingPrompt = PairingPromptState()
var targetedOffers = TargetedOfferState()
/// Peers with a mutation in flight; their row actions are disabled.
var busyPeerIds: Set<String> = []
var busyTransferIds: Set<String> = []
/// Peer whose label editor is open, or nil when closed.
var labelingPeerId: String?
var labelDraft = ""
var isSavingLabel = false
/// Peer the user chose to send to, or nil when no composition is open.
var sendTargetPeerId: String?
/// Sources chosen for the pending targeted send.
var sendFiles: [PickedShareFile] = []
var sendTransferName = ""
var isCreatingSend = false
/// True when the device being labelled already has one, so "Clear" has
/// something to clear.
var hasExistingLabel: Bool {
guard let peerId = labelingPeerId else { return false }
return device(peerId)?.localLabel?.isEmpty == false
}
/// Saving is pointless when the draft matches what is already stored.
var canSaveLabel: Bool {
guard let peerId = labelingPeerId, !isSavingLabel else { return false }
let draft = labelDraft.trimmingCharacters(in: .whitespacesAndNewlines)
let current = device(peerId)?.localLabel ?? ""
return draft != current
}
/// A targeted transfer needs a destination, at least one source, and a name
/// the same composition rules as an invitation share.
var canCreateTargetedTransfer: Bool {
sendTargetPeerId != nil
&& !sendFiles.isEmpty
&& !sendTransferName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !isCreatingSend
}
var isEmpty: Bool {
savedDevices.isEmpty && pendingRelationships.isEmpty && eligibilities.isEmpty
}
func transfers(for peerEndpointId: String) -> [SavedDeviceTransferItem] {
targetedTransfers.filter { $0.peerEndpointId == peerEndpointId }
}
func device(_ peerEndpointId: String) -> SavedDeviceModel? {
savedDevices.first { $0.endpointId == peerEndpointId }
}
}
/// Product-level Saved-device experience, ported from `SavedDevicesViewModel.kt`.
/// Views observe one snapshot and issue named commands; pairing, targeted offers,
/// transfer history, and receive destinations stay internal.
@MainActor
final class SavedDevicesModel: ObservableObject {
@Published private(set) var state = SavedDevicesState()
/// Requests a file/folder pick, consumed by the view layer (mirrors SendModel).
@Published var pendingFilePick = false
@Published var pendingFolderPick = false
private let repository: CoreGateway
private let fileSystemService: FileSystemService
private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>()
/// Serializes `refresh` so overlapping signals cannot interleave their reads
/// and publish a torn snapshot (the `refreshMutex` in the KMP model).
private var refreshTask: Task<Void, Never>?
/// Eligibilities the user dismissed this session. Dismissal is not a decline:
/// it suppresses the prompt locally without consuming the core's single-use
/// capability, so the device stays actionable from the list.
private var dismissedEligibility: Set<String> = []
private var receiveFolder: ReceiveFolder?
init(
repository: CoreGateway,
fileSystemService: FileSystemService,
preferences: AppPreferencesRepository,
messages: UiMessageController
) {
self.repository = repository
self.fileSystemService = fileSystemService
self.messages = messages
// Re-resolve the destination whenever the configured folder changes, and
// load once the core is up. Both inputs gate the first refresh: a receive
// has nowhere to land until the folder is known.
preferences.$preferences
.map(\.receiveFolder)
.combineLatest(repository.statePublisher.map(\.isInitialized))
.removeDuplicates { $0 == $1 }
.sink { [weak self] folder, isInitialized in
guard let self else { return }
self.receiveFolder = self.fileSystemService.effectiveReceiveFolder(folder)
if isInitialized { self.scheduleRefresh() }
}
.store(in: &cancellables)
repository.signals
.sink { [weak self] signal in
guard let self else { return }
switch signal {
case .pairingChanged, .targetedTransferChanged:
// Wake-up only: re-read durable state rather than trusting the
// event payload (see DESIGN-DEVICE-HISTORY.md §13).
if self.repository.state.isInitialized { self.scheduleRefresh() }
case .approvalChanged, .receiverHistoryChanged, .transfersChanged:
// Invitation-share domain; owned by SendModel.
break
}
}
.store(in: &cancellables)
}
// MARK: - Loading
func retry() {
guard repository.state.isInitialized, !state.isLoading else { return }
scheduleRefresh()
}
// MARK: - Pairing prompt
func acceptPairingPrompt() {
respondToPrompt(accepted: true)
}
func declinePairingPrompt() {
respondToPrompt(accepted: false)
}
/// Hides the prompt without answering it. Only meaningful for an eligibility
/// an incoming request stays until it is explicitly answered.
func dismissPairingPrompt() {
guard let prompt = state.pairingPrompt.prompt else { return }
if case .eligibility(let peerId, _) = prompt { dismissedEligibility.insert(peerId) }
state.pairingPrompt.prompt = nil
}
private func respondToPrompt(accepted: Bool) {
guard let prompt = state.pairingPrompt.prompt, !state.pairingPrompt.busy else { return }
state.pairingPrompt.busy = true
Task {
let result: Result<Void, Error>
switch (prompt, accepted) {
case (.eligibility(let peerId, _), true):
result = await repository.requestSavedDevicePairing(peerEndpointId: peerId).map { _ in () }
case (.eligibility(let peerId, _), false):
result = await repository.declinePairingEligibility(peerEndpointId: peerId)
case (.incomingRequest(let peerId, _), _):
result = await repository
.respondToDevicePairing(peerEndpointId: peerId, accepted: accepted)
.map { _ in () }
}
state.pairingPrompt.busy = false
switch result {
case .success: await refresh()
case .failure(let error): messages.error(error)
}
}
}
// MARK: - Per-device consent actions
func rememberEligible(_ peerEndpointId: String) {
mutatePeer(peerEndpointId) {
await self.repository.requestSavedDevicePairing(peerEndpointId: peerEndpointId).map { _ in () }
}
}
func declineEligible(_ peerEndpointId: String) {
mutatePeer(peerEndpointId) {
await self.repository.declinePairingEligibility(peerEndpointId: peerEndpointId)
}
}
func acceptIncoming(_ peerEndpointId: String) {
mutatePeer(peerEndpointId) {
await self.repository
.respondToDevicePairing(peerEndpointId: peerEndpointId, accepted: true)
.map { _ in () }
}
}
func declineIncoming(_ peerEndpointId: String) {
mutatePeer(peerEndpointId) {
await self.repository
.respondToDevicePairing(peerEndpointId: peerEndpointId, accepted: false)
.map { _ in () }
}
}
// MARK: - Targeted offers
func acceptTargetedOffer(_ transferId: String) {
respondToTargetedOffer(transferId, accepted: true)
}
func declineTargetedOffer(_ transferId: String) {
respondToTargetedOffer(transferId, accepted: false)
}
private func respondToTargetedOffer(_ transferId: String, accepted: Bool) {
guard !state.targetedOffers.respondingIds.contains(transferId) else { return }
state.targetedOffers.respondingIds.insert(transferId)
Task {
let response = await repository.respondToTargetedOffer(
transferId: transferId, accepted: accepted
)
switch response {
case .success(let outcome):
// Approval only authorizes the pull; the receiver still has to run
// it. `alreadySettled` is the idempotent replay path and must not
// start a second one.
if accepted, case .approved(let approvedId) = outcome {
if case .failure(let error) = await pullTargetedTransfer(approvedId, resume: false) {
messages.error(error)
}
}
await refresh()
case .failure(let error):
messages.error(error)
}
state.targetedOffers.respondingIds.remove(transferId)
}
}
// MARK: - Targeted transfer lifecycle
func receiveTargetedTransfer(_ transferId: String) {
mutateTransfer(transferId) { await self.pullTargetedTransfer(transferId, resume: false) }
}
func resumeTargetedTransfer(_ transferId: String) {
mutateTransfer(transferId) { await self.pullTargetedTransfer(transferId, resume: true) }
}
func cancelTargetedTransfer(_ transferId: String) {
mutateTransfer(transferId) { await self.repository.cancelTargetedTransfer(id: transferId) }
}
func deleteTargetedTransfer(_ transferId: String) {
mutateTransfer(transferId) { await self.repository.deleteTargetedTransfer(id: transferId) }
}
private func pullTargetedTransfer(_ transferId: String, resume: Bool) async -> Result<Void, Error> {
guard let folder = receiveFolder else {
// Preferences have not resolved a usable destination yet; the pull has
// nowhere to land. Surfaces as the generic filesystem error.
return .failure(InvitationError.filesystemUnavailable)
}
let result = resume
? await repository.resumeTargetedTransfer(id: transferId, outputDirectoryUrl: folder.value)
: await repository.receiveTargetedTransfer(
transferId: transferId, outputDirectoryUrl: folder.value
)
if case .success = result {
messages.tryShow(UiMessage(text: .resource(L10n.Receive.completed), tone: .success))
}
return result
}
// MARK: - Send
/// Opens targeted-send composition for a saved device.
func beginSend(to peerEndpointId: String) {
guard !state.busyPeerIds.contains(peerEndpointId) else { return }
let discarded = state.sendFiles
state.sendTargetPeerId = peerEndpointId
state.sendFiles = []
state.sendTransferName = ""
discardPickedFiles(discarded)
}
func cancelSend() {
guard !state.isCreatingSend else { return }
let discarded = state.sendFiles
state.sendTargetPeerId = nil
state.sendFiles = []
state.sendTransferName = ""
discardPickedFiles(discarded)
}
func selectSendFiles() { pendingFilePick = true }
func selectSendFolder() { pendingFolderPick = true }
func onSendFilesPicked(_ files: [PickedShareFile]) {
guard !files.isEmpty else { return }
// Replacing a selection discards the picker copies the previous one owned.
let selectedValues = Set(files.map(\.value))
let discarded = state.sendFiles.filter { !selectedValues.contains($0.value) }
state.sendFiles = files
state.sendTransferName = defaultTransferName(files)
discardPickedFiles(discarded)
}
func onSendFilePickFailed(_ reason: String) {
messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
}
func removeSendFile(_ value: String) {
let discarded = state.sendFiles.filter { $0.value == value }
let remaining = state.sendFiles.filter { $0.value != value }
// Keep a name the user typed; only re-derive one we generated.
let wasDefault = state.sendTransferName == defaultTransferName(state.sendFiles)
state.sendFiles = remaining
state.sendTransferName = remaining.isEmpty
? ""
: (wasDefault ? defaultTransferName(remaining) : state.sendTransferName)
discardPickedFiles(discarded)
}
func clearSendFiles() {
let discarded = state.sendFiles
state.sendFiles = []
state.sendTransferName = ""
discardPickedFiles(discarded)
}
func setSendTransferName(_ value: String) {
guard !state.isCreatingSend else { return }
state.sendTransferName = value
}
/// Creates the targeted transfer. The receiver still has to approve it a
/// saved device never grants automatic receipt.
func createTargetedTransfer() {
guard state.canCreateTargetedTransfer, let peerId = state.sendTargetPeerId else { return }
let files = state.sendFiles
let name = state.sendTransferName.trimmingCharacters(in: .whitespacesAndNewlines)
state.isCreatingSend = true
Task {
let result = await fileSystemService.sendPickedFilesToSavedDevice(
repository: repository,
files: files,
transferName: name,
receiverEndpointId: peerId
)
state.isCreatingSend = false
switch result {
case .success:
messages.tryShow(
UiMessage(text: .resource(L10n.Saved.devicesSendStarted), tone: .success)
)
state.sendTargetPeerId = nil
state.sendFiles = []
state.sendTransferName = ""
// The core owns the bytes now; release any picker copies.
discardPickedFiles(files)
await refresh()
case .failure(let error):
// Keep the composition intact so the user can retry without
// re-picking, mirroring the label editor's failure behavior.
messages.error(error)
}
}
}
private func defaultTransferName(_ files: [PickedShareFile]) -> String {
guard let first = files.first else { return "" }
return files.count == 1
? first.displayName
: L10n.Send.selectedFilesCount(count: files.count)
}
private func discardPickedFiles(_ files: [PickedShareFile]) {
guard !files.isEmpty else { return }
Task { await fileSystemService.discardPickedFiles(files) }
}
// MARK: - Label editing
func openLabelEditor(_ peerEndpointId: String) {
guard !state.isSavingLabel, !state.busyPeerIds.contains(peerEndpointId) else { return }
state.labelingPeerId = peerEndpointId
state.labelDraft = state.device(peerEndpointId)?.localLabel ?? ""
}
func setLabelDraft(_ value: String) {
// Frozen while saving so the committed value cannot drift from the draft
// the user is looking at.
guard !state.isSavingLabel else { return }
state.labelDraft = value
}
func dismissLabelEditor() {
// Refuse to close mid-save: the draft must survive to be retried.
guard !state.isSavingLabel else { return }
state.labelingPeerId = nil
state.labelDraft = ""
}
func saveLabel() {
let trimmed = state.labelDraft.trimmingCharacters(in: .whitespacesAndNewlines)
commitLabel(trimmed.isEmpty ? nil : trimmed)
}
func clearLabel() {
commitLabel(nil)
}
/// Transactional from the UI's perspective: on failure the draft and the
/// editor survive so the user can retry; the editor closes only after the
/// core confirms the write.
private func commitLabel(_ label: String?) {
guard let peerId = state.labelingPeerId else { return }
guard !state.isSavingLabel, !state.busyPeerIds.contains(peerId) else { return }
state.isSavingLabel = true
state.busyPeerIds.insert(peerId)
Task {
let result = await repository.setSavedDeviceLabel(peerEndpointId: peerId, label: label)
switch result {
case .success:
await refresh()
messages.tryShow(UiMessage(text: .resource(L10n.Saved.devicesLabeled), tone: .success))
case .failure(let error):
messages.error(error)
}
state.busyPeerIds.remove(peerId)
state.isSavingLabel = false
// Only close the editor the user still has open on this peer they may
// have switched to another device while the write was in flight.
if case .success = result, state.labelingPeerId == peerId {
state.labelingPeerId = nil
state.labelDraft = ""
}
}
}
// MARK: - Destructive actions
func forget(_ peerEndpointId: String) {
mutatePeer(peerEndpointId) {
let result = await self.repository.forgetSavedDevice(peerEndpointId: peerEndpointId)
if case .success = result {
self.messages.tryShow(
UiMessage(text: .resource(L10n.Saved.devicesForgotten), tone: .success)
)
}
return result
}
}
func block(_ peerEndpointId: String) {
mutatePeer(peerEndpointId) {
let result = await self.repository.blockDevice(peerEndpointId: peerEndpointId)
if case .success = result {
self.messages.tryShow(
UiMessage(text: .resource(L10n.Saved.devicesBlocked), tone: .success)
)
}
return result
}
}
// MARK: - Mutation helpers
private func mutatePeer(
_ peerEndpointId: String,
_ block: @escaping () async -> Result<Void, Error>
) {
guard !state.busyPeerIds.contains(peerEndpointId) else { return }
state.busyPeerIds.insert(peerEndpointId)
Task {
switch await block() {
case .success: await refresh()
case .failure(let error): messages.error(error)
}
state.busyPeerIds.remove(peerEndpointId)
}
}
private func mutateTransfer(
_ transferId: String,
_ block: @escaping () async -> Result<Void, Error>
) {
guard !state.busyTransferIds.contains(transferId) else { return }
state.busyTransferIds.insert(transferId)
Task {
switch await block() {
case .success: await refresh()
case .failure(let error): messages.error(error)
}
state.busyTransferIds.remove(transferId)
}
}
// MARK: - Refresh
/// Coalesces refreshes onto one serial chain. Signals can arrive in bursts;
/// without this their reads interleave and publish a torn snapshot.
private func scheduleRefresh() {
let previous = refreshTask
refreshTask = Task {
await previous?.value
await refresh()
}
}
private func refresh() async {
state.isLoading = true
state.loadFailed = false
// Five reads make one snapshot; if any fails the snapshot is incomplete, so
// surface the failure rather than render a partial list. Sequential by
// design: the gateway funnels core calls through one serial dispatcher, so
// issuing these concurrently would queue behind each other anyway.
do {
let eligibilities = try await repository.listPairingEligibilities().get()
let relationships = try await repository.listDeviceRelationships().get()
let savedDevices = try await repository.listSavedDevices().get()
let pendingOffers = try await repository.listPendingTargetedOffers().get()
let transfers = try await repository.listTargetedTransfers().get()
let savedNames = savedDevices.reduce(into: [String: String]()) { names, device in
if let name = device.displayNameOrNil { names[device.endpointId] = name }
}
let localEndpointId = repository.state.status?.endpointId
let pendingRelationships = relationships
.filter { $0.state == .pendingIncoming || $0.state == .pendingOutgoing }
.sorted { $0.updatedAt > $1.updatedAt }
state.isLoading = false
state.loadFailed = false
state.eligibilities = eligibilities.sorted { $0.createdAt > $1.createdAt }
state.pendingRelationships = pendingRelationships
state.savedDevices = savedDevices.sorted { $0.createdAt > $1.createdAt }
state.targetedTransfers = transfers
.filter { $0.state != .deleted }
.sorted { $0.updatedAt > $1.updatedAt }
.map { $0.toExperienceItem(localEndpointId: localEndpointId, savedNames: savedNames) }
// Leave a prompt mid-answer alone; replacing it would strand the
// in-flight request behind a prompt the user never saw.
if !state.pairingPrompt.busy {
state.pairingPrompt = PairingPromptState(
prompt: nextPairingPrompt(
relationships: pendingRelationships,
eligibilities: eligibilities,
savedNames: savedNames
)
)
}
state.targetedOffers.pending = pendingOffers.sorted { $0.receivedAt < $1.receivedAt }
state.targetedOffers.senderDisplayNames = savedNames
} catch {
state.isLoading = false
state.loadFailed = true
messages.error(error)
}
}
/// An incoming request outranks an eligibility: the peer is waiting on us,
/// and answering it is the only action that unblocks them.
private func nextPairingPrompt(
relationships: [DeviceRelationshipModel],
eligibilities: [PairingEligibilityModel],
savedNames: [String: String]
) -> PairingPrompt? {
if let incoming = relationships.first(where: { $0.state == .pendingIncoming }) {
let name = eligibilities
.first { $0.peerEndpointId == incoming.remoteEndpointId }?
.remoteDisplayName
?? savedNames[incoming.remoteEndpointId]
return .incomingRequest(peerEndpointId: incoming.remoteEndpointId, remoteDisplayName: name)
}
guard let eligibility = eligibilities.first(where: {
!dismissedEligibility.contains($0.peerEndpointId)
}) else { return nil }
return .eligibility(
peerEndpointId: eligibility.peerEndpointId,
remoteDisplayName: eligibility.remoteDisplayName
)
}
}
private extension TargetedTransferModel {
/// Resolves the transfer against the local endpoint so the UI can speak in
/// terms of "the peer" and a direction.
func toExperienceItem(
localEndpointId: String?,
savedNames: [String: String]
) -> SavedDeviceTransferItem {
let outgoing = senderEndpointId == localEndpointId
let peerEndpointId = outgoing ? receiverEndpointId : senderEndpointId
return SavedDeviceTransferItem(
id: id,
peerEndpointId: peerEndpointId,
peerDisplayName: savedNames[peerEndpointId],
direction: outgoing ? .outgoing : .incoming,
transferName: transferName,
fileCount: fileCount,
totalSize: totalSize,
verifiedBytes: verifiedBytes,
state: state,
createdAt: createdAt,
updatedAt: updatedAt
)
}
}

View File

@@ -0,0 +1,248 @@
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: .laptopcomputerAndIphone)
} 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)
}
}

View File

@@ -0,0 +1,216 @@
import SwiftUI
import SFSafeSymbols
/// Source composition for a targeted send. Deliberately mirrors the invitation
/// composer's file/folder/rename/replace/clear affordances the two domains stay
/// distinct after creation, but choosing what to send works the same way.
struct TargetedSendComposer: View {
@ObservedObject var model: SavedDevicesModel
let deviceName: String
private var state: SavedDevicesState { model.state }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if state.sendFiles.isEmpty {
chooseStep
} else {
reviewStep
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.savedDeviceSendPickers(model: model)
}
private var chooseStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text(String(localized: L10n.Send.chooseFileTitle))
.font(.title2)
.fontWeight(.semibold)
Text(recipientLine)
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(spacing: 14) {
Image(systemSymbol: .doc)
.font(.system(size: 30))
.foregroundStyle(.tint)
PrimaryButton(
title: String(localized: L10n.Button.chooseFiles),
action: model.selectSendFiles
)
.fixedSize()
QuietButton(
title: String(localized: L10n.Button.chooseFolder),
action: model.selectSendFolder
)
}
.frame(maxWidth: .infinity)
.padding(28)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 16))
}
}
private var reviewStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text(String(localized: L10n.Send.reviewTitle))
.font(.title2)
.fontWeight(.semibold)
Text(recipientLine)
.font(.subheadline)
.foregroundStyle(.secondary)
ForEach(state.sendFiles) { file in
TargetedSourceCard(
file: file,
canRemove: state.sendFiles.count > 1 && !state.isCreatingSend,
onRemove: { model.removeSendFile(file.value) }
)
}
Field(
label: String(localized: L10n.Field.transferName),
value: Binding(
get: { state.sendTransferName },
set: { model.setSendTransferName($0) }
),
enabled: !state.isCreatingSend
)
// Every targeted transfer still needs the receiver to approve it;
// saying so here sets the right expectation before sending.
Label(
String(localized: L10n.Send.accessApprovalDescription),
systemSymbol: .checkmarkShield
)
.font(.caption)
.foregroundStyle(.secondary)
actions
}
}
private var actions: some View {
VStack(spacing: 10) {
PrimaryButton(
title: String(localized: L10n.Saved.devicesSendAction),
action: model.createTargetedTransfer,
enabled: state.canCreateTargetedTransfer
)
HStack(spacing: 10) {
sourceButton(
title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectSendFiles
)
sourceButton(
title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectSendFolder
)
sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSendFiles)
}
}
}
private func sourceButton(
title: String.LocalizationValue,
symbol: SFSymbol,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(String(localized: title), systemSymbol: symbol)
.lineLimit(1)
.minimumScaleFactor(0.85)
.frame(maxWidth: .infinity)
.frame(minHeight: 20)
}
.buttonStyle(.bordered)
.controlSize(.large)
.tint(.secondary)
.disabled(state.isCreatingSend)
}
private var recipientLine: String {
L10n.Saved.devicesTransferDirectionOutgoing(device: deviceName)
}
}
private struct TargetedSourceCard: View {
let file: PickedShareFile
let canRemove: Bool
let onRemove: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemSymbol: file.isDirectory ? .folder : .doc)
.foregroundStyle(.secondary)
.frame(width: 44, height: 44)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 3) {
Text(file.displayName).lineLimit(1)
Text(subtitle).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if canRemove {
Button(role: .destructive, action: onRemove) {
Image(systemSymbol: .trash)
}
.buttonStyle(.borderless)
.tint(.red)
}
}
.padding(14)
.frame(maxWidth: .infinity)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 14))
}
private var subtitle: String {
if file.isDirectory { return String(localized: L10n.Send.folderLabel) }
if let size = file.sizeBytes { return formatBytes(size) }
return String(localized: L10n.Send.fileSizeUnknown)
}
}
/// File/folder picker for targeted send. Attached to the composer so it presents
/// from the composer's own sheet rather than the already-presenting root the
/// same constraint `SendPickers` documents.
private struct SavedDeviceSendPickers: ViewModifier {
@ObservedObject var model: SavedDevicesModel
func body(content: Content) -> some View {
// One .fileImporter switched between files and folders: stacking two on a
// single view silently breaks, with the second shadowing the first.
content.fileImporter(
isPresented: Binding(
get: { model.pendingFilePick || model.pendingFolderPick },
set: { presented in
if !presented {
model.pendingFilePick = false
model.pendingFolderPick = false
}
}
),
allowedContentTypes: model.pendingFolderPick ? [.folder] : [.item],
allowsMultipleSelection: !model.pendingFolderPick
) { result in
let isDirectory = model.pendingFolderPick
model.pendingFilePick = false
model.pendingFolderPick = false
switch result {
case .success(let urls):
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: isDirectory) }
if files.isEmpty {
model.onSendFilePickFailed("")
} else {
model.onSendFilesPicked(files)
}
case .failure(let error):
if !error.isUserCancellation { model.onSendFilePickFailed(error.technicalDetail) }
}
}
}
}
extension View {
func savedDeviceSendPickers(model: SavedDevicesModel) -> some View {
modifier(SavedDeviceSendPickers(model: model))
}
}

View File

@@ -96,6 +96,11 @@ final class SendModel: ObservableObject {
case .receiverHistoryChanged(let id), .approvalChanged(let id):
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
self.refreshReceiverStatuses(for: id)
case .pairingChanged, .targetedTransferChanged:
// Saved-device domain; owned by SavedDevicesModel. Listed
// explicitly rather than via `default` so a new signal still
// forces a decision here.
break
}
}
.store(in: &cancellables)

View File

@@ -73,6 +73,24 @@ struct IosFileSystemService: FileSystemService {
)
}
func sendPickedFilesToSavedDevice(
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
receiverEndpointId: String
) async -> Result<TargetedTransferModel, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
// iOS picks by copying into the container, so no security scope has to be
// re-acquired here (unlike macOS).
return await repository.createTargetedTransfer(
receiverEndpointId: receiverEndpointId,
sources: files.map { $0.toIosShareSource() },
transferName: transferName.isEmpty ? nil : transferName
)
}
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
let url = URL(string: value) ?? URL(fileURLWithPath: value)
let started = url.startAccessingSecurityScopedResource()

View File

@@ -44,10 +44,42 @@ struct MacFileSystemService: FileSystemService {
guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
// Re-acquire security-scoped access to every picked source (from the bookmark
// captured at pick time) and hold it across the whole share call. The core
// imports the bytes during shareFiles(), so access only needs to survive that
// call; without this, the import fails with EPERM under the App Store sandbox.
guard case .invitation(let accessPolicy) = destination else {
return .failure(InvitationError.unsupportedOperation)
}
return await withScopedSources(files) { sources in
await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
}
}
func sendPickedFilesToSavedDevice(
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
receiverEndpointId: String
) async -> Result<TargetedTransferModel, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
return await withScopedSources(files) { sources in
await repository.createTargetedTransfer(
receiverEndpointId: receiverEndpointId,
sources: sources,
transferName: transferName.isEmpty ? nil : transferName
)
}
}
/// Re-acquires security-scoped access to every picked source (from the bookmark
/// captured at pick time) and holds it across `body`. The core imports the bytes
/// during that call, so access only needs to survive it; without this the import
/// fails with EPERM under the App Store sandbox.
private func withScopedSources<T>(
_ files: [PickedShareFile],
_ body: ([ShareSource]) async -> Result<T, Error>
) async -> Result<T, Error> {
var scopedURLs: [URL] = []
for file in files {
guard let bookmark = file.securityScopeBookmark else { continue }
@@ -63,12 +95,7 @@ struct MacFileSystemService: FileSystemService {
let sources = files.map {
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
}
guard case .invitation(let accessPolicy) = destination else {
return .failure(InvitationError.unsupportedOperation)
}
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
return await body(sources)
}
}
#endif

View File

@@ -5,6 +5,7 @@ import SFSafeSymbols
enum AppDestination: String, CaseIterable, Identifiable {
case send
case receive
case savedDevices
case settings
var id: String { rawValue }
@@ -13,6 +14,7 @@ enum AppDestination: String, CaseIterable, Identifiable {
switch self {
case .send: return L10n.Nav.send
case .receive: return L10n.Nav.receive
case .savedDevices: return L10n.Nav.savedDevices
case .settings: return L10n.Nav.settings
}
}
@@ -22,6 +24,7 @@ enum AppDestination: String, CaseIterable, Identifiable {
switch self {
case .send: return .paperplane
case .receive: return .trayAndArrowDown
case .savedDevices: return .laptopcomputerAndIphone
case .settings: return .gearshape
}
}