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