6 Commits

Author SHA1 Message Date
6ac2faf063 fix(apple): drop unused iOS background mode and empty Bonjour entry
UIBackgroundModes declared `remote-notification`, but the app has no APNs
code: notifications are local only, through UNUserNotificationCenter. The
background transfer assertion uses beginBackgroundTask, which needs no
declared mode. An unused background mode is gratuitous review surface.

NSBonjourServices held a single empty string, which is not a valid service
type. Nothing in the core browses or advertises Bonjour services — iroh's
local-discovery feature is not enabled — so the key served no purpose.
NSLocalNetworkUsageDescription stays: direct connections to peers on the
same network still need it.
2026-08-14 09:32:41 +02:00
442a533eae fix(l10n): translate the saved-device strings
22 keys carried the English text in every locale. Copied English does not
look unfinished — it looks shipped: the key is present and non-empty, so
`validate` passes and it reaches users as a French or Russian build that
silently speaks English. 21 came in with the saved-devices work; one was
added alongside the send-cancellation fix by copying its neighbours.

Translations follow the vocabulary already established in the file —
appareil enregistré, dispositivo guardado, Kopplung, Mémoriser/Ricorda/
Zapamiętaj, and European Portuguese ficheiros/guardado.

Only format_separated_pair and format_separated_triple still read the same
in every locale. They are pure punctuation ("{first} · {second}") with no
words to translate.

Documents the rule in the README, including why it is worse than a missing
key and the trap that spread it here: copying a neighbour's shape without
checking the neighbour was translated.
2026-08-13 22:33:41 +02:00
6b6d5f158d fix(apple): make cancelling a targeted send actually cancel
Creating a targeted transfer contacts the peer and only returns once the
offer is answered or its timeouts expire — connection_timeout plus
offer_wait_timeout, so minutes against a device that never answers. The
composer disabled every control for that whole window, including Close,
leaving no way out. Worse, the core records the row before it reaches out
and leaves it in `failed` when the peer never replies, so giving up still
produced a failure notification and a history entry for a send the user
had already called off.

Cancelling now reaches the core while that create is still running. The
`created` lifecycle event carries the transfer id and is emitted before
the peer is contacted, so the id is known in time; the cancel goes out
through the interrupt lane, which exists precisely to reach a core busy
inside another call. The transfer is then deleted, and its id is filtered
out of the published list so a refresh racing the delete cannot leak it
into history or into a notification. If the id has not arrived yet, the
result carries it and the same cleanup runs on return. Picked sources are
released only once the call lands, because the import owns them until
then, and a generation counter keeps a late result from disturbing a
newer send.

Close and Cancel were also the same action under two labels. There is now
one control: the sheet's cancellation item, reading Cancel while a send is
waiting and Close otherwise, never disabled.

Two further fixes from device testing:

- Receive and Resume were gated on transfer state alone, so an approved
  outgoing transfer offered the sender a Receive button — an invitation to
  download the files it was uploading. Both pull into a local folder and
  are now receiver-only.
- Renamed the deprecated `laptopcomputerAndIphone` symbol to
  `macbookAndIphone`; the deployment targets are well past where it was
  introduced, so no availability guard is needed.

Adds a typed `targetedTransferId` accessor on CoreEventModel. This is a
narrow, deliberate exception to the wake-up-only event rule in
DESIGN-DEVICE-HISTORY.md §13: it takes the subject id and never state,
and it exists because no query can answer while the create holds the
serial lane — which is exactly when the user wants to cancel.

Known gap: direction is inferred by comparing endpoint ids, because the
binding does not expose the row's role. After an identity reset, rows
predating it match neither endpoint, so past sends read as incoming from
the device's own retired identity. Fixing that needs `role` on the core's
TargetedTransfer.
2026-08-13 22:33:06 +02:00
2d4fcd78b5 Merge remote-tracking branch 'origin/feat/device-history' into feat/device-history-apple
# Conflicts:
#	apple/VniDrop/App/RootView.swift
#	apple/VniDrop/Features/App/AppModel.swift
2026-08-13 22:31:38 +02:00
0ea9a8e49c fix(core): recover unrecoverable device identity 2026-08-13 20:49:41 +02:00
8bb1442338 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.
2026-08-13 19:42:37 +02:00
54 changed files with 5295 additions and 612 deletions

View File

@@ -161,6 +161,15 @@ A crash at any step must preserve at least one valid copy and must not change
the endpoint identity. Confirmed unrecoverable loss or an explicit identity the endpoint identity. Confirmed unrecoverable loss or an explicit identity
reset is required before replacement. reset is required before replacement.
An identity reset is a destructive, user-confirmed recovery operation exposed
before normal core initialization. It is accepted only when the protected
endpoint credential is missing or corrupted; a readable identity can never be
reset through this path. The reset preserves received files and transfer
history, stops old active Invitation transfers, cancels resumable Targeted
transfers, and removes relationships, pairing eligibility, grants,
authorizations, and retry state bound to the lost identity. A replacement
identity is minted only after that invalidation transaction commits.
Secrets must not synchronize through platform cloud backup. Restored metadata Secrets must not synchronize through platform cloud backup. Restored metadata
without its device-bound secrets reconciles to disabled relationships, never a without its device-bound secrets reconciles to disabled relationships, never a
cloned identity. cloned identity.

View File

@@ -95,6 +95,13 @@ label, forget, and block operations. Wrap those calls through the existing
Apple `CoreGateway` / `CoreRepository` boundary rather than invoking generated Apple `CoreGateway` / `CoreRepository` boundary rather than invoking generated
bindings from SwiftUI views. bindings from SwiftUI views.
If startup reports a missing or corrupted endpoint identity, platforms must
offer the explicit `resetUnrecoverableIdentityWithLimitsAndNetworkConfig`
recovery flow rather than an endless Retry action. Confirm that Saved devices
must be paired again; transfer history and received files are retained. Locked
or temporarily unavailable credential storage remains retryable and must not
offer identity replacement.
Use SF Symbols and native iOS/macOS controls even when that duplicates Compose Use SF Symbols and native iOS/macOS controls even when that duplicates Compose
presentation code. Share behavior and vocabulary across platforms, not widget presentation code. Share behavior and vocabulary across platforms, not widget
implementations. Before handoff, run `make check-localization` and implementations. Before handoff, run `make check-localization` and

View File

@@ -1,4 +1,5 @@
import XCTest import XCTest
import VnidropCore
@testable import VniDrop @testable import VniDrop
/// Ports app-level assertions: core initialization on launch, destination /// Ports app-level assertions: core initialization on launch, destination
@@ -50,4 +51,21 @@ final class AppModelTests: XCTestCase {
await waitUntil { model.themeMode == .dark } await waitUntil { model.themeMode == .dark }
XCTAssertEqual(model.themeMode, .dark) XCTAssertEqual(model.themeMode, .dark)
} }
func testMissingEndpointIdentityOffersExplicitResetAndRecoversStartup() async {
let core = FakeCoreGateway()
core.initializeResult = .failure(
VnidropError.SecureStorageMissing(reason: "credential is missing")
)
let model = makeModel(core, preferences: Fixtures.preferences())
await waitUntil { model.startupRecovery == .identityUnrecoverable }
XCTAssertFalse(core.state.isInitialized)
await model.resetUnrecoverableIdentity()
XCTAssertEqual(core.resetUnrecoverableIdentityCount, 1)
XCTAssertNil(model.startupRecovery)
XCTAssertTrue(core.state.isInitialized)
}
} }

View File

@@ -40,6 +40,14 @@ private final class BlockingCoreBindingFactory: CoreBindingFactory, @unchecked S
throw BlockingCoreFactoryError.stopped throw BlockingCoreFactoryError.stopped
} }
func resetUnrecoverableIdentity(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
throw BlockingCoreFactoryError.stopped
}
func waitUntilInitializationStarts() async { func waitUntilInitializationStarts() async {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
lock.lock() lock.lock()

View File

@@ -27,6 +27,7 @@ final class FakeCoreGateway: CoreGateway {
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0) var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
var initializeResult: Result<Void, Error> = .success(()) var initializeResult: Result<Void, Error> = .success(())
var initializeResults: [Result<Void, Error>] = [] var initializeResults: [Result<Void, Error>] = []
var resetUnrecoverableIdentityResult: Result<Void, Error> = .success(())
// Recorded calls // Recorded calls
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = [] private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
@@ -38,8 +39,27 @@ final class FakeCoreGateway: CoreGateway {
private(set) var lastReceiveReceiverName: String? private(set) var lastReceiveReceiverName: String?
private(set) var lastShareAccessPolicy: ShareAccessPolicy? private(set) var lastShareAccessPolicy: ShareAccessPolicy?
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = [] private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
private(set) var resetUnrecoverableIdentityCount = 0
func setState(_ state: CoreState) { stateSubject.send(state) } func setState(_ state: CoreState) { stateSubject.send(state) }
/// Publishes the `created` lifecycle event the core emits once a targeted
/// transfer row exists, then the signal that follows it the sequence that
/// lets a caller cancel a create still holding the serial lane.
func emitTargetedTransferCreated(id: String) {
var state = stateSubject.value
state.events.insert(
CoreEventModel(
id: "event-\(id)", revision: 1, timestamp: 1, scope: "endpoint", transferId: nil,
direction: nil, phase: EventPhase.targetedTransfer.rawValue,
kind: EventKind.created.rawValue,
dataJson: #"{"targeted_transfer_id":"\#(id)"}"#
),
at: 0
)
stateSubject.send(state)
emit(.targetedTransferChanged)
}
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) } func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
func initialize( func initialize(
@@ -54,6 +74,19 @@ final class FakeCoreGateway: CoreGateway {
stateSubject.send(s) stateSubject.send(s)
return .success(()) return .success(())
} }
func resetUnrecoverableIdentity(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
resetUnrecoverableIdentityCount += 1
guard case .success = resetUnrecoverableIdentityResult else {
return resetUnrecoverableIdentityResult
}
var s = stateSubject.value
s.isInitialized = true
stateSubject.send(s)
return .success(())
}
func shutdown() {} func shutdown() {}
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> { func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
lastShareAccessPolicy = accessPolicy lastShareAccessPolicy = accessPolicy
@@ -81,6 +114,141 @@ final class FakeCoreGateway: CoreGateway {
return responseResult return responseResult
} }
func refresh() async -> Result<Void, Error> { .success(()) } 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
}
/// Holds `createTargetedTransfer` open until `releaseTargetedCreate()`, so a
/// test can observe the model while a create is genuinely in flight the
/// state the user is stuck in when the receiving device never answers.
var holdsTargetedCreate = false
private var targetedCreateGate: CheckedContinuation<Void, Never>?
/// True once the call is parked on the gate. `isCreatingSend` flips before the
/// task body runs, so releasing on that alone can resume nothing and hang.
var isHoldingTargetedCreate: Bool { targetedCreateGate != nil }
func releaseTargetedCreate() {
let gate = targetedCreateGate
targetedCreateGate = nil
gate?.resume()
}
func createTargetedTransfer(
receiverEndpointId: String, sources: [ShareSource], transferName: String?
) async -> Result<TargetedTransferModel, Error> {
createdTargetedTransfers.append((receiverEndpointId, sources, transferName))
if holdsTargetedCreate {
await withCheckedContinuation { targetedCreateGate = $0 }
}
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. /// Minimal `FileSystemService` fake a writable path receive folder, no reveal.
@@ -103,6 +271,31 @@ final class FakeFileSystemService: FileSystemService {
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy [], 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 @MainActor

View File

@@ -39,19 +39,20 @@ final class SavedDeviceCoreContractTests: XCTestCase {
defer { try? FileManager.default.removeItem(at: directory) } defer { try? FileManager.default.removeItem(at: directory) }
let sink = RecordingSink() let sink = RecordingSink()
let first = try VnidropCore.initializeWithLimitsAndNetworkConfig( var first: VnidropCore? = try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: directory.path, appDataDir: directory.path,
eventSink: sink, eventSink: sink,
limits: defaultCoreLimits(), limits: defaultCoreLimits(),
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: []) networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
) )
let endpointId = first.status().endpointId let endpointId = first!.status().endpointId
XCTAssertFalse(endpointId.isEmpty) XCTAssertFalse(endpointId.isEmpty)
XCTAssertFalse( XCTAssertFalse(
FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path), FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path),
"protected identity must not fall back to plaintext" "protected identity must not fall back to plaintext"
) )
first.shutdown() first?.shutdown()
first = nil
let restarted = try VnidropCore.initializeWithLimitsAndNetworkConfig( let restarted = try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: directory.path, appDataDir: directory.path,
@@ -134,6 +135,9 @@ final class SavedDeviceCoreContractTests: XCTestCase {
let _: ( let _: (
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore (String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
) = VnidropCore.initializeWithLimitsAndNetworkConfig ) = VnidropCore.initializeWithLimitsAndNetworkConfig
let _: (
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
) = VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig
let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities() let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities()
XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1) XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1)
XCTAssertNotNil(defaultCoreLimits().maxSavedDevices) XCTAssertNotNil(defaultCoreLimits().maxSavedDevices)
@@ -165,6 +169,7 @@ final class SavedDeviceCoreContractTests: XCTestCase {
XCTAssertFalse(source.contains("ExperimentalSavedDeviceCapabilities")) XCTAssertFalse(source.contains("ExperimentalSavedDeviceCapabilities"))
XCTAssertFalse(source.contains("experimentalSavedDeviceCapabilities")) XCTAssertFalse(source.contains("experimentalSavedDeviceCapabilities"))
XCTAssertTrue(source.contains("initializeWithLimitsAndNetworkConfig")) XCTAssertTrue(source.contains("initializeWithLimitsAndNetworkConfig"))
XCTAssertTrue(source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"))
XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities")) XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities"))
XCTAssertTrue(source.contains("public func savedDeviceCapabilities()")) XCTAssertTrue(source.contains("public func savedDeviceCapabilities()"))
XCTAssertTrue(source.contains("setSavedDeviceLabel")) XCTAssertTrue(source.contains("setSavedDeviceLabel"))

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,669 @@
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: - Transfer actions
func testOnlyTheReceivingSideIsOfferedReceiveAndResume() async {
gateway.savedDevices = [savedDevice()]
gateway.targetedTransfers = [
transfer(id: "in-approved", sender: Self.peer, receiver: Self.localEndpoint, state: .approved),
transfer(id: "out-approved", sender: Self.localEndpoint, receiver: Self.peer, state: .approved),
transfer(id: "in-interrupted", sender: Self.peer, receiver: Self.localEndpoint, state: .interrupted),
transfer(id: "out-interrupted", sender: Self.localEndpoint, receiver: Self.peer, state: .interrupted),
]
let model = await makeModel()
let byId = Dictionary(uniqueKeysWithValues: model.state.targetedTransfers.map { ($0.id, $0) })
XCTAssertEqual(byId["in-approved"]?.canReceive, true)
XCTAssertEqual(byId["in-interrupted"]?.canResume, true)
// The sender has nothing to pull: it is the one holding the files. Offering
// "Receive" there asked it to download its own outgoing transfer.
XCTAssertEqual(byId["out-approved"]?.canReceive, false)
XCTAssertEqual(byId["out-interrupted"]?.canResume, false)
}
// 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"])
}
/// Sets up a create that never returns until released an unavailable peer,
/// where the core waits out its connection and offer timeouts. `announcesId`
/// mirrors the core emitting `created` once the row exists, which it does
/// before it ever contacts the peer.
private func stalledSend(
_ model: SavedDevicesModel,
id: String = "t-inflight",
announcesId: Bool = true
) async {
gateway.holdsTargetedCreate = true
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt")])
model.createTargetedTransfer()
await waitUntil { self.gateway.isHoldingTargetedCreate }
if announcesId {
gateway.emitTargetedTransferCreated(id: id)
await waitUntil { !model.state.isCreatingSend || model.knowsInFlightSendTransfer }
}
XCTAssertTrue(model.state.isCreatingSend)
}
func testCancellingAnUnansweredSendReachesTheCoreWhileItIsStillRunning() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
let model = await makeModel()
await stalledSend(model)
model.abandonSend()
// The point of cancelling: it must not wait for the create to finish. The
// cancel goes out while the core is still parked inside that very call.
await waitUntil { !self.gateway.cancelledTargetedTransfers.isEmpty }
XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t-inflight"])
XCTAssertFalse(model.state.isCreatingSend)
XCTAssertNil(model.state.sendTargetPeerId)
gateway.releaseTargetedCreate()
}
func testCancelledSendIsDeletedRatherThanLeftInHistory() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
let model = await makeModel()
await stalledSend(model)
model.abandonSend()
await waitUntil { !self.gateway.deletedTargetedTransfers.isEmpty }
// Cancelling is not "closing the sheet": the core would otherwise record a
// failed transfer, and the user would be told a send they called off failed.
XCTAssertEqual(gateway.deletedTargetedTransfers, ["t-inflight"])
gateway.releaseTargetedCreate()
}
func testCancelledSendNeverReachesHistoryEvenIfARefreshRacesTheDelete() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
// The core reports it as failed the state an unanswered offer lands in.
gateway.targetedTransfers = [transfer(id: "t-inflight", state: .failed)]
let model = await makeModel()
await stalledSend(model)
model.abandonSend()
gateway.releaseTargetedCreate()
await waitUntil { !self.gateway.deletedTargetedTransfers.isEmpty }
// Nothing about the cancelled send may surface, or the notification
// coordinator announces a failure for work the user called off.
XCTAssertTrue(model.state.targetedTransfers.isEmpty)
}
func testCancellingReleasesTheComposerBeforeTheCreateReturns() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
let model = await makeModel()
await stalledSend(model)
model.abandonSend()
XCTAssertFalse(model.state.isCreatingSend)
XCTAssertTrue(model.state.sendFiles.isEmpty)
// The import still owns the sources until the call lands.
XCTAssertTrue(fileSystem.discardedFiles.isEmpty)
gateway.releaseTargetedCreate()
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"])
}
func testCancelledSendWithNoIdYetIsStillCleanedUpWhenTheCreateReturns() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-late"))
let model = await makeModel()
// The create beat its own `created` event, so cancelling has no id to use.
await stalledSend(model, announcesId: false)
model.abandonSend()
XCTAssertTrue(gateway.cancelledTargetedTransfers.isEmpty)
gateway.releaseTargetedCreate()
// The result carries the id, so the cleanup still happens just later.
await waitUntil { !self.gateway.cancelledTargetedTransfers.isEmpty }
XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t-late"])
XCTAssertEqual(gateway.deletedTargetedTransfers, ["t-late"])
}
func testCancelledSendThatNeverRegisteredHasNothingToCancel() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .failure(TestError.unimplemented)
let model = await makeModel()
await stalledSend(model, announcesId: false)
model.abandonSend()
gateway.releaseTargetedCreate()
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
XCTAssertTrue(gateway.cancelledTargetedTransfers.isEmpty)
// The composition is gone, so a failure the user walked away from is not
// resurrected as an error they have to dismiss.
XCTAssertTrue(model.state.sendFiles.isEmpty)
}
func testCancelledResultDoesNotDisturbANewerSend() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
let model = await makeModel()
await stalledSend(model)
model.abandonSend()
// The user starts composing again while the first call is still pending.
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("b.txt")])
gateway.releaseTargetedCreate()
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
XCTAssertEqual(model.state.sendTargetPeerId, Self.peer)
XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/b.txt"])
XCTAssertFalse(model.state.isCreatingSend)
}
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

@@ -63,6 +63,9 @@ final class UserFacingErrorTests: XCTestCase {
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput)) XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput) XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput) XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.SecureStorageMissing(reason: "credential is missing").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.SecureStorageCorrupted(reason: "credential is corrupted").canRetryWithoutChangingInput)
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput) XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)
XCTAssertTrue(VnidropError.SecureStorageLocked(reason: "credential store is locked").canRetryWithoutChangingInput)
} }
} }

View File

@@ -9,6 +9,9 @@ struct RootView: View {
@StateObject private var sendModel: SendModel @StateObject private var sendModel: SendModel
@StateObject private var receiveModel: ReceiveModel @StateObject private var receiveModel: ReceiveModel
@StateObject private var settingsModel: SettingsModel @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 @Environment(\.scenePhase) private var scenePhase
@@ -44,6 +47,21 @@ struct RootView: View {
messages: graph.messages, messages: graph.messages,
bugReports: NoopBugReportService() 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 { var body: some View {
@@ -52,6 +70,13 @@ struct RootView: View {
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark) let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
ZStack { ZStack {
navigation(windowClass: windowClass) 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` // Observe the coordinator/messages from the *persisted* `graph`
// StateObject. Deriving them in `init` bound the view to a throwaway // StateObject. Deriving them in `init` bound the view to a throwaway
// AppGraph rebuilt on every re-init, whose coordinator never receives // AppGraph rebuilt on every re-init, whose coordinator never receives
@@ -68,7 +93,16 @@ struct RootView: View {
// A small, unobtrusive indicator while the core finishes its async // A small, unobtrusive indicator while the core finishes its async
// startup otherwise the lists look empty and the app feels stalled. // startup otherwise the lists look empty and the app feels stalled.
if !sendModel.coreState.isInitialized { if !sendModel.coreState.isInitialized {
CoreStartingOverlay() CoreStartingOverlay(
error: appModel.startupError,
detail: appModel.startupErrorDetail,
onRetry: appModel.retryStartup,
recovery: appModel.startupRecovery,
isResettingIdentity: appModel.isResettingIdentity,
onResetIdentity: {
Task { await appModel.resetUnrecoverableIdentity() }
}
)
} }
} }
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized) .animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
@@ -167,6 +201,8 @@ struct RootView: View {
switch destination { switch destination {
case .send: SendScreen(model: sendModel, windowClass: windowClass) case .send: SendScreen(model: sendModel, windowClass: windowClass)
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass) case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
case .savedDevices:
SavedDevicesScreen(model: savedDevicesModel, windowClass: windowClass)
case .settings: case .settings:
SettingsScreen(model: settingsModel, windowClass: windowClass) SettingsScreen(model: settingsModel, windowClass: windowClass)
} }
@@ -253,11 +289,84 @@ 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 { private struct CoreStartingOverlay: View {
let error: UiText?
/// Debug builds only; nil in Release.
let detail: String?
let onRetry: () -> Void
let recovery: AppStartupRecovery?
let isResettingIdentity: Bool
let onResetIdentity: () -> Void
@State private var confirmsIdentityReset = false
var body: some View { var body: some View {
ZStack { ZStack {
backgroundColor.ignoresSafeArea() backgroundColor.ignoresSafeArea()
// A repairable identity comes first: it is the one failure the user can
// actually act on, and its own copy explains the consequences.
if recovery == .identityUnrecoverable {
VStack(spacing: 18) {
Image(systemSymbol: .exclamationmarkTriangleFill)
.font(.system(size: 44))
.foregroundStyle(.orange)
Text(String(localized: L10n.App.identityResetTitle))
.font(.title2.bold())
Text(String(localized: L10n.App.identityResetMessage))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 420)
Button(role: .destructive) {
confirmsIdentityReset = true
} label: {
if isResettingIdentity {
ProgressView()
} else {
Text(String(localized: L10n.App.identityResetAction))
}
}
.buttonStyle(.borderedProminent)
.disabled(isResettingIdentity)
}
.padding(32)
} else 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) { VStack(spacing: 16) {
ProgressView().controlSize(.large) ProgressView().controlSize(.large)
Text(String(localized: L10n.App.starting)) Text(String(localized: L10n.App.starting))
@@ -265,9 +374,30 @@ private struct CoreStartingOverlay: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
}
.transition(.opacity) .transition(.opacity)
.alert(
String(localized: L10n.App.identityResetTitle),
isPresented: $confirmsIdentityReset
) {
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
Button(String(localized: L10n.App.identityResetAction), role: .destructive) {
onResetIdentity()
}
} message: {
Text(String(localized: L10n.App.identityResetConfirmation))
}
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityLabel(Text(String(localized: L10n.App.starting))) .accessibilityLabel(Text(accessibilityLabel))
}
/// Mirrors the three visual states, so VoiceOver never announces "Starting"
/// over a screen that has actually stopped and is asking for a decision.
private var accessibilityLabel: String {
if recovery == .identityUnrecoverable {
return String(localized: L10n.App.identityResetTitle)
}
return error?.resolved() ?? String(localized: L10n.App.starting)
} }
private var backgroundColor: Color { private var backgroundColor: Color {
@@ -284,10 +414,3 @@ import UIKit
#else #else
import AppKit import AppKit
#endif #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

@@ -25,6 +25,7 @@ protocol CoreGateway: AnyObject {
var signals: AnyPublisher<CoreSignal, Never> { get } var signals: AnyPublisher<CoreSignal, Never> { get }
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error> func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
func resetUnrecoverableIdentity(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
func shutdown() func shutdown()
func shareSources( func shareSources(
_ sources: [ShareSource], _ sources: [ShareSource],
@@ -47,4 +48,51 @@ protocol CoreGateway: AnyObject {
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
func refresh() 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

@@ -27,6 +27,30 @@ struct CoreEventModel: Equatable, Identifiable, Sendable {
var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) } var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) }
var eventPhase: EventPhase? { EventPhase(rawValue: phase) } var eventPhase: EventPhase? { EventPhase(rawValue: phase) }
var eventKind: EventKind? { EventKind(rawValue: kind) } var eventKind: EventKind? { EventKind(rawValue: kind) }
/// Subject of a `.targetedTransfer` event.
///
/// This is an identifier, not state: it says *which* transfer changed, which
/// is all a consumer may take from an event before re-reading durable state.
/// It exists because the id is otherwise unobtainable while `create` is still
/// running that call occupies the serial lane, so no query can answer until
/// it returns, which is exactly when the user wants to cancel it.
var targetedTransferId: String? {
guard eventPhase == .targetedTransfer, let data = dataJson.data(using: .utf8) else {
return nil
}
return try? JSONDecoder().decode(TargetedTransferEventData.self, from: data).targetedTransferId
}
}
/// Payload of a `.targetedTransfer` event. Named keys rather than a raw string
/// subscript so the wire contract lives in one declared place.
private struct TargetedTransferEventData: Decodable {
let targetedTransferId: String?
private enum CodingKeys: String, CodingKey {
case targetedTransferId = "targeted_transfer_id"
}
} }
/// Direction of a core event, matching the wire strings the core emits. /// Direction of a core event, matching the wire strings the core emits.
@@ -47,6 +71,12 @@ enum EventPhase: String, Equatable, Sendable {
case network case network
case handshake case handshake
case error 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). /// Kind of a core progress event (the `kind` wire field).
@@ -174,6 +204,13 @@ enum CoreSignal: Equatable, Sendable {
case receiverHistoryChanged(transferId: UInt64) case receiverHistoryChanged(transferId: UInt64)
/// Transfer status/history changed enough to re-read the durable snapshot. /// Transfer status/history changed enough to re-read the durable snapshot.
case transfersChanged(transferId: UInt64) 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) // MARK: - Transfer helpers (ported from AppUiModels.kt)

View File

@@ -28,36 +28,50 @@ protocol CoreBindingFactory: Sendable {
eventSink: CoreEventSink, eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration networkConfiguration: RelayConfiguration
) throws -> VnidropCore ) throws -> VnidropCore
func resetUnrecoverableIdentity(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore
} }
struct NativeCoreBindingFactory: CoreBindingFactory { struct NativeCoreBindingFactory: CoreBindingFactory {
private func nativeConfiguration(_ networkConfiguration: RelayConfiguration) -> CoreNetworkConfig {
switch networkConfiguration.mode {
case .automatic:
return defaultCoreNetworkConfig()
case .strictCustom:
return CoreNetworkConfig(mode: .strictCustom, relayUrls: networkConfiguration.relayURLs)
case .customWithDirectFallback:
return CoreNetworkConfig(mode: .customWithDirectFallback, relayUrls: networkConfiguration.relayURLs)
case .localOnly:
return CoreNetworkConfig(mode: .localOnly, relayUrls: [])
}
}
func initialize( func initialize(
appDataDir: String, appDataDir: String,
eventSink: CoreEventSink, eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration networkConfiguration: RelayConfiguration
) throws -> VnidropCore { ) throws -> VnidropCore {
let nativeConfiguration: CoreNetworkConfig
switch networkConfiguration.mode {
case .automatic:
nativeConfiguration = defaultCoreNetworkConfig()
case .strictCustom:
nativeConfiguration = CoreNetworkConfig(
mode: .strictCustom,
relayUrls: networkConfiguration.relayURLs
)
case .customWithDirectFallback:
nativeConfiguration = CoreNetworkConfig(
mode: .customWithDirectFallback,
relayUrls: networkConfiguration.relayURLs
)
case .localOnly:
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
}
return try VnidropCore.initializeWithLimitsAndNetworkConfig( return try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: appDataDir, appDataDir: appDataDir,
eventSink: eventSink, eventSink: eventSink,
limits: defaultCoreLimits(), limits: defaultCoreLimits(),
networkConfig: nativeConfiguration networkConfig: nativeConfiguration(networkConfiguration)
)
}
func resetUnrecoverableIdentity(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
try VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig(
appDataDir: appDataDir,
eventSink: eventSink,
limits: defaultCoreLimits(),
networkConfig: nativeConfiguration(networkConfiguration)
) )
} }
} }
@@ -139,6 +153,35 @@ final class CoreRepository: ObservableObject, CoreGateway {
} }
} }
func resetUnrecoverableIdentity(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
isNetworkTransitionInProgress = true
defer { isNetworkTransitionInProgress = false }
let result = await runCore { [sink] in
let created = try self.coreFactory.resetUnrecoverableIdentity(
appDataDir: appDataDir,
eventSink: sink,
networkConfiguration: networkConfiguration
)
self.core = created
return created
}
switch result {
case .success:
refreshSnapshot()
state.isInitialized = true
return .success(())
case .failure(let error):
state = CoreState()
return .failure(error)
}
}
func shutdown() { func shutdown() {
core?.shutdown() core?.shutdown()
core = nil core = nil
@@ -293,6 +336,140 @@ final class CoreRepository: ObservableObject, CoreGateway {
if let snapshot { self.applySnapshot(snapshot) } 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) // MARK: - Event sink handling (ported from CoreRepository.sink)
private func handle(event: CoreEvent) { private func handle(event: CoreEvent) {
@@ -302,6 +479,21 @@ final class CoreRepository: ObservableObject, CoreGateway {
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) } if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
state.events = events 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 } guard let transferId = model.transferId else { return }
switch model.phase { switch model.phase {
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId)) case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
@@ -497,6 +689,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 { private extension ReceiverRequest {
func toModel() -> ReceiverRequestModel { func toModel() -> ReceiverRequestModel {
ReceiverRequestModel( ReceiverRequestModel(
@@ -522,4 +806,3 @@ private extension ReceiverRequest {

View File

@@ -43,6 +43,15 @@ protocol FileSystemService {
senderName: String, senderName: String,
destination: ShareDestination destination: ShareDestination
) async -> Result<Share, Error> ) 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 { 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

@@ -1,5 +1,10 @@
import Foundation import Foundation
import Combine import Combine
import VnidropCore
enum AppStartupRecovery: Equatable {
case identityUnrecoverable
}
/// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the /// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the
/// core on launch and tracks the selected destination + theme. /// core on launch and tracks the selected destination + theme.
@@ -7,10 +12,24 @@ import Combine
final class AppModel: ObservableObject { final class AppModel: ObservableObject {
@Published private(set) var destination: AppDestination = .send @Published private(set) var destination: AppDestination = .send
@Published private(set) var themeMode: ThemeMode = .system @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.
/// Stays nil for failures `startupRecovery` can offer a repair for, so the
/// user is shown the repair rather than a dead end.
@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?
@Published private(set) var startupRecovery: AppStartupRecovery?
@Published private(set) var isResettingIdentity = false
private let environment: PlatformEnvironment private let environment: PlatformEnvironment
private let repository: CoreGateway private let repository: CoreGateway
private let messages: UiMessageController private let messages: UiMessageController
private let appDataDir: String
private let networkConfiguration: RelayConfiguration
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init( init(
@@ -22,16 +41,12 @@ final class AppModel: ObservableObject {
self.environment = environment self.environment = environment
self.repository = repository self.repository = repository
self.messages = messages self.messages = messages
self.appDataDir = environment.defaultCoreDataDir
self.networkConfiguration = preferences.preferences.relayConfiguration
AppLogger.info("lifecycle", "app started", ["platform": environment.name]) AppLogger.info("lifecycle", "app started", ["platform": environment.name])
Task { Task { await initializeCore() }
let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: preferences.preferences.relayConfiguration
)
if case .failure(let error) = result { messages.error(error) }
}
preferences.$preferences preferences.$preferences
.map(\.themeMode) .map(\.themeMode)
@@ -40,8 +55,68 @@ final class AppModel: ObservableObject {
.store(in: &cancellables) .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
startupRecovery = nil
let result = await repository.initialize(
appDataDir: appDataDir,
networkConfiguration: networkConfiguration
)
if case .failure(let error) = result {
AppLogger.error("lifecycle", "core initialization failed", error)
// A repairable identity gets the reset flow instead of a generic
// failure, which would offer only a retry that cannot succeed.
if error.hasUnrecoverableEndpointIdentity {
startupRecovery = .identityUnrecoverable
return
}
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) { func selectDestination(_ destination: AppDestination) {
guard destination != self.destination else { return } guard destination != self.destination else { return }
self.destination = destination self.destination = destination
} }
func resetUnrecoverableIdentity() async {
guard startupRecovery == .identityUnrecoverable, !isResettingIdentity else { return }
isResettingIdentity = true
defer { isResettingIdentity = false }
let result = await repository.resetUnrecoverableIdentity(
appDataDir: appDataDir,
networkConfiguration: networkConfiguration
)
switch result {
case .success:
startupRecovery = nil
startupError = nil
startupErrorDetail = nil
case .failure(let error):
AppLogger.error("lifecycle", "identity reset failed", error)
messages.error(error)
}
}
}
private extension Error {
var hasUnrecoverableEndpointIdentity: Bool {
guard let error = self as? VnidropError else { return false }
switch error {
case .SecureStorageMissing, .SecureStorageCorrupted: return true
default: return false
}
}
} }

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) } Task { await self.syncReceivers(transferId: transferId) }
case .approvalChanged: case .approvalChanged:
break break
case .pairingChanged, .targetedTransferChanged:
// Saved-device notifications are owned by the saved-device
// coordinator, which tracks its own notification identifiers.
break
} }
} }
.store(in: &cancellables) .store(in: &cancellables)

View File

@@ -0,0 +1,577 @@
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: one state each, receiving side only.
private var primaryAction: TransferAction? {
if transfer.canReceive {
return TransferAction(id: "receive", title: L10n.Saved.devicesTransferReceive, run: onReceive)
}
if transfer.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) {
// Never disabled: a create in flight can take minutes against an
// unavailable device, and a dead Close left no way out at all.
// It reads "Cancel" while waiting, because that is what leaving
// now does abandoning keeps the sources alive for the core's
// import and cleans them up once the call lands.
Button(String(localized: model.state.isCreatingSend
? L10n.Button.cancel
: L10n.Button.close)) {
if model.state.isCreatingSend {
model.abandonSend()
} else {
model.cancelSend()
}
}
}
}
}
.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,87 @@
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))
}
/// Pulling content is the receiving side's move. Gating on state alone put a
/// "Receive" button on the sender's own outgoing transfer, offering to
/// download the files it was in the middle of sending.
var canReceive: Bool { direction == .incoming && state.canReceive }
/// Resuming likewise pulls into a local folder, so it is receiver-only.
var canResume: Bool { direction == .incoming && state.canResume }
}

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 = .macbookAndIphone
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,715 @@
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> = []
/// Identifies each create attempt so a result arriving after the user gave up
/// can tell whether it is the one that was abandoned.
private var sendGeneration = 0
private var abandonedSendGeneration: Int?
/// The transfer the in-flight create registered, learned from its `created`
/// event. Cancelling needs an id, and the create does not return one until it
/// has already spent its timeouts against a device that may never answer.
private var inFlightSendTransferId: String?
/// Transfers the user walked away from. They are cancelled and deleted, but
/// until that lands they stay out of the published list so history and
/// notifications never mention a transfer the user called off.
private var abandonedTransferIds: 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). The one
// exception is noting *which* transfer a create just made, which
// no query can answer while that create holds the serial lane.
self.noteInFlightSendTransferId()
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)
}
/// Cancels a create that is still in flight.
///
/// The create reaches out to the peer and only returns once the offer is
/// answered or its timeouts expire, which against an unavailable device is
/// minutes. So this cancels the transfer by id off the serial lane, which
/// is what lets it reach a core busy inside that very call and drops the
/// composer immediately. Cancelling is not merely closing the sheet: without
/// it the core would go on to record a failed transfer, then announce and
/// list a send the user had already called off.
func abandonSend() {
guard state.isCreatingSend else { return }
abandonedSendGeneration = sendGeneration
state.isCreatingSend = false
state.sendTargetPeerId = nil
state.sendFiles = []
state.sendTransferName = ""
guard let transferId = inFlightSendTransferId else { return }
abandonedTransferIds.insert(transferId)
Task { await cancelAndForget(transferId) }
}
/// Whether the in-flight create has announced the transfer it registered, and
/// so whether cancelling can reach it now rather than after the call returns.
var knowsInFlightSendTransfer: Bool { inFlightSendTransferId != nil }
/// Captures the id of the transfer the in-flight create just registered. The
/// core inserts the row and emits `created` before it contacts the peer, so
/// this lands well before the wait the user gives up on.
private func noteInFlightSendTransferId() {
guard state.isCreatingSend, inFlightSendTransferId == nil else { return }
let created = repository.state.events.first {
$0.eventPhase == .targetedTransfer && $0.eventKind == .created
}
guard let id = created?.targetedTransferId else { return }
inFlightSendTransferId = id
}
/// Cancels an abandoned transfer and removes it from history. Both are best
/// effort: the user has moved on, so a failure here is logged rather than
/// raised as an error about work they already dismissed.
private func cancelAndForget(_ transferId: String) async {
if case .failure(let error) = await repository.cancelTargetedTransfer(id: transferId) {
AppLogger.error("saved-devices", "abandoned send cancel failed", error)
}
if case .failure(let error) = await repository.deleteTargetedTransfer(id: transferId) {
AppLogger.error("saved-devices", "abandoned send delete failed", error)
}
await refresh()
}
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)
sendGeneration &+= 1
let generation = sendGeneration
inFlightSendTransferId = nil
state.isCreatingSend = true
Task {
let result = await fileSystemService.sendPickedFilesToSavedDevice(
repository: repository,
files: files,
transferName: name,
receiverEndpointId: peerId
)
// The user walked away from this attempt, and may already have started
// another one; touching the composer now would stomp that newer state.
if abandonedSendGeneration == generation {
await reconcileAbandonedSend(result, files: files)
return
}
state.isCreatingSend = false
inFlightSendTransferId = nil
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)
}
}
}
/// Runs when an abandoned create finally returns. The cancel itself already
/// went out by id; this releases the sources, which the core's import owned
/// until now, and covers the case where the id never arrived the create
/// beat its own `created` event, or failed before registering anything.
private func reconcileAbandonedSend(
_ result: Result<TargetedTransferModel, Error>,
files: [PickedShareFile]
) async {
inFlightSendTransferId = nil
discardPickedFiles(files)
if case .success(let transfer) = result, !abandonedTransferIds.contains(transfer.id) {
abandonedTransferIds.insert(transfer.id)
await cancelAndForget(transfer.id)
return
}
await refresh()
}
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
// A cancelled-and-deleted transfer can still be in this snapshot if
// the read raced the delete. Publishing it would put a send the user
// called off into history and fire a notification about it.
.filter { $0.state != .deleted && !abandonedTransferIds.contains($0.id) }
.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: .macbookAndIphone)
} description: {
Text(String(localized: L10n.Saved.devicesEmpty))
}
}
}
// MARK: - Rows
private struct SavedDeviceRow: View {
let device: SavedDeviceModel
let transferCount: Int
let busy: Bool
var body: some View {
HStack(spacing: 12) {
DeviceAvatar()
VStack(alignment: .leading, spacing: 3) {
Text(device.displayName)
.font(.body)
.lineLimit(1)
.truncationMode(.tail)
EndpointIdLabel(endpointId: device.endpointId)
}
Spacer(minLength: 8)
if busy {
ProgressView().controlSize(.small)
} else if transferCount > 0 {
Text(verbatim: "\(transferCount)")
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
Image(systemSymbol: .chevronRight)
.font(.caption)
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
}
/// A relationship awaiting consent. Incoming requests get accept/decline; an
/// outgoing request is informational until the peer answers.
private struct PendingRelationshipRow: View {
let relationship: DeviceRelationshipModel
let busy: Bool
let onAccept: () -> Void
let onDecline: () -> Void
private var isIncoming: Bool { relationship.state == .pendingIncoming }
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
DeviceAvatar(symbol: .personBadgeClock, tint: .orange)
VStack(alignment: .leading, spacing: 3) {
Text(String(localized: isIncoming
? L10n.Saved.devicesPendingIncoming
: L10n.Saved.devicesPendingOutgoing))
.font(.body)
.lineLimit(2)
EndpointIdLabel(endpointId: relationship.remoteEndpointId)
}
Spacer(minLength: 0)
if busy { ProgressView().controlSize(.small) }
}
if isIncoming {
HStack(spacing: 10) {
Button(String(localized: L10n.Saved.devicesAcceptPairingAction), action: onAccept)
.buttonStyle(.borderedProminent)
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: onDecline)
.buttonStyle(.bordered)
}
.controlSize(.small)
.disabled(busy)
}
}
.padding(.vertical, 4)
}
}
/// A peer we completed a transfer with and may ask to pair. Naming it uses the
/// peer's untrusted hint the only name available before the device is saved.
private struct EligibilityRow: View {
let eligibility: PairingEligibilityModel
let busy: Bool
let onRemember: () -> Void
let onDecline: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
DeviceAvatar(symbol: .checkmarkSealFill, tint: VniDropColors.brandPurple)
VStack(alignment: .leading, spacing: 3) {
Text(eligibility.remoteDisplayName ?? String(localized: L10n.Saved.devicesUnnamed))
.font(.body)
.lineLimit(1)
Text(String(localized: L10n.Saved.devicesEligibilityTitle))
.font(.caption)
.foregroundStyle(.secondary)
EndpointIdLabel(endpointId: eligibility.peerEndpointId)
}
Spacer(minLength: 0)
if busy { ProgressView().controlSize(.small) }
}
HStack(spacing: 10) {
Button(String(localized: L10n.Saved.devicesRememberAction), action: onRemember)
.buttonStyle(.borderedProminent)
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: onDecline)
.buttonStyle(.bordered)
}
.controlSize(.small)
.disabled(busy)
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,230 @@
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) {
if state.isCreatingSend {
// Reaching an unavailable device can take minutes, so the wait says
// so. Abandoning it lives on the toolbar's cancel item alone a
// second button here would be the same action under a second name.
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text(String(localized: L10n.Saved.devicesSendWaiting))
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
} else {
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): case .receiverHistoryChanged(let id), .approvalChanged(let id):
if id == self.state.selectedTransferId { self.refreshReceivers(id) } if id == self.state.selectedTransferId { self.refreshReceivers(id) }
self.refreshReceiverStatuses(for: 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) .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 { private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
let url = URL(string: value) ?? URL(fileURLWithPath: value) let url = URL(string: value) ?? URL(fileURLWithPath: value)
let started = url.startAccessingSecurityScopedResource() let started = url.startAccessingSecurityScopedResource()

View File

@@ -44,10 +44,42 @@ struct MacFileSystemService: FileSystemService {
guard !files.isEmpty else { guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty) return .failure(InvitationError.shareEmpty)
} }
// Re-acquire security-scoped access to every picked source (from the bookmark guard case .invitation(let accessPolicy) = destination else {
// captured at pick time) and hold it across the whole share call. The core return .failure(InvitationError.unsupportedOperation)
// 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. 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] = [] var scopedURLs: [URL] = []
for file in files { for file in files {
guard let bookmark = file.securityScopeBookmark else { continue } guard let bookmark = file.securityScopeBookmark else { continue }
@@ -63,12 +95,7 @@ struct MacFileSystemService: FileSystemService {
let sources = files.map { let sources = files.map {
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory) ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
} }
guard case .invitation(let accessPolicy) = destination else { return await body(sources)
return .failure(InvitationError.unsupportedOperation)
}
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
} }
} }
#endif #endif

View File

@@ -75,10 +75,6 @@
<true/> <true/>
<key>NFCReaderUsageDescription</key> <key>NFCReaderUsageDescription</key>
<string>VniDrop uses NFC to read transfer invitation tags.</string> <string>VniDrop uses NFC to read transfer invitation tags.</string>
<key>NSBonjourServices</key>
<array>
<string></string>
</array>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>VniDrop uses the camera to scan transfer QR codes.</string> <string>VniDrop uses the camera to scan transfer QR codes.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
@@ -87,10 +83,6 @@
view. Mirrors the single-window macOS behavior. --> view. Mirrors the single-window macOS behavior. -->
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
<false/> <false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIFileSharingEnabled</key> <key>UIFileSharingEnabled</key>
<true/> <true/>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>

View File

@@ -107,7 +107,9 @@ extension Error {
var canRetryWithoutChangingInput: Bool { var canRetryWithoutChangingInput: Bool {
guard let vni = self as? VnidropError else { return true } guard let vni = self as? VnidropError else { return true }
switch vni { switch vni {
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false case .FilesystemPermission, .DestinationExists, .InvalidInput,
.SecureStorageMissing, .SecureStorageCorrupted:
return false
default: return true default: return true
} }
} }

View File

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

View File

@@ -61,7 +61,13 @@ bytes through Kotlin memory.
work before durable cleanup; forget and block revoke affected relationships work before durable cleanup; forget and block revoke affected relationships
and active targeted work within their core operation. All four durably deny and active targeted work within their core operation. All four durably deny
reuse and perform idempotent payload/secret cleanup. reuse and perform idempotent payload/secret cleanup.
7. Saved devices, relationships, pairing eligibility, and targeted transfers are 7. A missing or corrupted endpoint credential remains fail-closed during normal
startup. The explicit pre-start identity-reset constructor accepts only that
unrecoverable state, atomically invalidates identity-bound relationships,
eligibility, targeted authorization, and retry state, then creates a new
protected identity. Transfer history and received files remain; old active
Invitation transfers are stopped and Saved devices must be paired again.
8. Saved devices, relationships, pairing eligibility, and targeted transfers are
shipped Rust core domains. Graduating the KMP and Apple Saved-device UI and shipped Rust core domains. Graduating the KMP and Apple Saved-device UI and
their existing experimental preference gates is outside this release gate. their existing experimental preference gates is outside this release gate.

View File

@@ -0,0 +1,127 @@
//! Explicit recovery for an unrecoverable protected endpoint identity.
use sqlx::{Row, SqlitePool};
use crate::{error::VnidropError, util::now_ms};
/// Owns the cross-domain transaction that invalidates trust bound to an old identity.
#[derive(Clone)]
pub(crate) struct IdentityRecoveryStore {
pool: SqlitePool,
}
impl IdentityRecoveryStore {
pub(crate) fn new(pool: SqlitePool) -> Self {
Self { pool }
}
/// Preserve completed history and received artifacts, but revoke every
/// capability that could authenticate or resume work as the lost identity.
pub(crate) async fn reset_identity_bound_state(&self) -> Result<Vec<String>, VnidropError> {
let now = now_ms();
let mut transaction = self.pool.begin().await.map_err(VnidropError::repository)?;
let handles = sqlx::query("SELECT handle FROM protected_secret_refs ORDER BY handle")
.fetch_all(&mut *transaction)
.await
.map_err(VnidropError::repository)?
.into_iter()
.map(|row| row.get::<String, _>(0))
.collect::<Vec<_>>();
sqlx::query(
r#"
UPDATE transfers
SET status = CASE
WHEN status = 'sharing' THEN 'stopped'
WHEN status IN ('importing', 'receiving') THEN 'cancelled'
ELSE status
END,
ticket = CASE
WHEN status IN ('sharing', 'importing', 'receiving') THEN NULL
ELSE ticket
END,
updated_at = CASE
WHEN status IN ('sharing', 'importing', 'receiving') THEN ?1
ELSE updated_at
END
WHERE status IN ('sharing', 'importing', 'receiving')
"#,
)
.bind(now)
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
sqlx::query(
r#"
UPDATE receiver_requests
SET status = CASE WHEN status = 'requested' THEN 'expired' ELSE 'failed' END,
reason = 'device identity reset',
responded_at = COALESCE(responded_at, ?1)
WHERE status IN ('requested', 'accepted')
"#,
)
.bind(now)
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
sqlx::query("DELETE FROM pending_delivery_receipts")
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
for table in [
"targeted_accepted_offer_intents",
"targeted_authorization_delivery_outbox",
"targeted_completion_outbox",
"targeted_payload_release_outbox",
] {
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
}
sqlx::query(
r#"
UPDATE targeted_transfers
SET state = CASE
WHEN state IN (
'preparing', 'offering', 'awaiting_approval', 'approved',
'connecting', 'transferring', 'interrupted'
) THEN 'cancelled'
ELSE state
END,
blob_ticket = NULL,
authorization_secret_handle = NULL,
updated_at = CASE
WHEN state IN (
'preparing', 'offering', 'awaiting_approval', 'approved',
'connecting', 'transferring', 'interrupted'
) OR blob_ticket IS NOT NULL OR authorization_secret_handle IS NOT NULL
THEN ?1
ELSE updated_at
END
"#,
)
.bind(now)
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
for table in [
"pairing_eligibilities",
"device_relationships",
"relationship_generation_tombstones",
"protected_secret_refs",
] {
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&mut *transaction)
.await
.map_err(VnidropError::repository)?;
}
transaction
.commit()
.await
.map_err(VnidropError::repository)?;
Ok(handles)
}
}

View File

@@ -9,6 +9,7 @@ mod event_hub;
mod filesystem; mod filesystem;
mod grant; mod grant;
mod handshake; mod handshake;
mod identity_recovery;
mod invitation; mod invitation;
mod logging; mod logging;
mod pairing_eligibility; mod pairing_eligibility;

View File

@@ -11,6 +11,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use crate::{ use crate::{
blocked_devices::{self, BlockStore}, blocked_devices::{self, BlockStore},
device_relationship::DeviceRelationshipStore, device_relationship::DeviceRelationshipStore,
identity_recovery::IdentityRecoveryStore,
invitation::Repository, invitation::Repository,
pairing_eligibility::PairingEligibilityStore, pairing_eligibility::PairingEligibilityStore,
secure_secret::{self, SecretMetadataStore}, secure_secret::{self, SecretMetadataStore},
@@ -32,6 +33,8 @@ pub(crate) struct AppDataStores {
pub(crate) secrets: SecretMetadataStore, pub(crate) secrets: SecretMetadataStore,
/// Identity-wide deny list. /// Identity-wide deny list.
pub(crate) blocked: BlockStore, pub(crate) blocked: BlockStore,
/// Explicit endpoint-identity reset transaction.
pub(crate) identity_recovery: IdentityRecoveryStore,
} }
/// Create the profile pool, apply all domain schemas, return [`AppDataStores`]. /// Create the profile pool, apply all domain schemas, return [`AppDataStores`].
@@ -66,6 +69,7 @@ pub(crate) async fn open_all(app_data_dir: &Path) -> Result<AppDataStores> {
relationships: DeviceRelationshipStore::new(pool.clone()), relationships: DeviceRelationshipStore::new(pool.clone()),
eligibility: PairingEligibilityStore::new(pool.clone()), eligibility: PairingEligibilityStore::new(pool.clone()),
secrets: SecretMetadataStore::new(pool.clone()), secrets: SecretMetadataStore::new(pool.clone()),
identity_recovery: IdentityRecoveryStore::new(pool.clone()),
blocked: BlockStore::new(pool), blocked: BlockStore::new(pool),
invitation, invitation,
}) })

View File

@@ -63,7 +63,7 @@ impl VnidropCore {
self.runtime.handle().block_on(future) self.runtime.handle().block_on(future)
} }
fn initialize_with_identity_mode( pub(super) fn initialize_with_identity_mode(
app_data_dir: String, app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>, event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits, limits: CoreLimits,
@@ -497,6 +497,24 @@ impl VnidropCore {
Self::initialize_protected(app_data_dir, event_sink, limits, network_config) Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
} }
/// Explicitly replace an endpoint identity whose protected credential is
/// missing or corrupted, invalidate identity-bound trust, and initialize.
/// A readable identity is never reset by this constructor.
#[uniffi::constructor]
pub fn reset_unrecoverable_identity_with_limits_and_network_config(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
Self::reset_unrecoverable_identity_protected(
app_data_dir,
event_sink,
limits,
network_config,
)
}
pub fn status(&self) -> RuntimeStatus { pub fn status(&self) -> RuntimeStatus {
self.block_on(self.inner.status()) self.block_on(self.inner.status())
} }

View File

@@ -0,0 +1,89 @@
//! Pre-start recovery for a missing or corrupted protected endpoint identity.
use std::{path::PathBuf, sync::Arc};
use super::{IdentityMode, VnidropCore};
use crate::{
api::{CoreEventSink, CoreLimits, CoreNetworkConfig},
error::VnidropError,
secure_secret::{lock_profile, platform_secret_store, SecretCustody, SecureSecretStore},
};
impl VnidropCore {
pub(super) fn reset_unrecoverable_identity_protected(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
let app_data_path = PathBuf::from(app_data_dir);
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
let app_data_path =
std::fs::canonicalize(app_data_path).map_err(VnidropError::filesystem)?;
let profile_lock = lock_profile(&app_data_path)?;
let store = platform_secret_store(&app_data_path)?;
Self::reset_unrecoverable_identity_with_store(
app_data_path,
event_sink,
limits,
network_config,
store,
profile_lock,
)
}
fn reset_unrecoverable_identity_with_store(
app_data_path: PathBuf,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
store: Arc<dyn SecureSecretStore>,
profile_lock: crate::secure_secret::ProfileLock,
) -> Result<Arc<Self>, VnidropError> {
let recovery_runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
recovery_runtime.block_on(async {
let stores = crate::persistence::open_all(&app_data_path)
.await
.map_err(VnidropError::repository)?;
let custody =
SecretCustody::for_explicit_identity_reset(stores.secrets.clone(), store.clone());
custody.require_unrecoverable_endpoint_identity().await?;
let handles = stores
.identity_recovery
.reset_identity_bound_state()
.await?;
custody.delete_reset_handles(handles).await
})?;
Self::initialize_with_identity_mode(
app_data_path.to_string_lossy().into_owned(),
event_sink,
limits,
network_config,
IdentityMode::Protected {
store,
profile_lock,
},
)
}
#[cfg(test)]
pub(crate) fn reset_unrecoverable_identity_with_test_secret_store(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
store: Arc<dyn SecureSecretStore>,
) -> Result<Arc<Self>, VnidropError> {
let app_data_path = PathBuf::from(&app_data_dir);
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
let profile_lock = crate::secure_secret::unlocked_profile_for_test(&app_data_path)?;
Self::reset_unrecoverable_identity_with_store(
app_data_path,
event_sink,
CoreLimits::default(),
CoreNetworkConfig::default(),
store,
profile_lock,
)
}
}

View File

@@ -11,6 +11,7 @@
mod delivery; mod delivery;
mod facade; mod facade;
mod identity_recovery;
mod lifecycle; mod lifecycle;
mod provider; mod provider;
mod receive; mod receive;

View File

@@ -389,6 +389,64 @@ impl SecretCustody {
} }
} }
pub(crate) fn for_explicit_identity_reset(
metadata: SecretMetadataStore,
store: Arc<dyn SecureSecretStore>,
) -> Self {
Self::from_parts(metadata, store)
}
/// Reject reset while the current identity remains readable. Missing or
/// corrupted endpoint custody is unrecoverable without an explicit reset.
pub(crate) async fn require_unrecoverable_endpoint_identity(&self) -> Result<(), VnidropError> {
let endpoints = self
.metadata
.list()
.await?
.into_iter()
.filter(|entry| {
entry.kind == SecretKind::EndpointIdentity
&& entry.state != SecretMetadataState::Disabled
})
.collect::<Vec<_>>();
if endpoints.is_empty() {
// Idempotent continuation after a reset transaction committed but
// the process stopped before a replacement identity was activated.
return Ok(());
}
for endpoint in endpoints {
match self.store_get_raw(endpoint.handle).await? {
Ok(material) => {
if validate_material(
SecretKind::EndpointIdentity,
&material,
endpoint.expected_identity.as_deref(),
)
.is_ok()
{
return Err(VnidropError::InvalidInput {
reason: "protected endpoint identity is still available".to_string(),
});
}
}
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {}
Err(error) => return Err(map_store_error(error)),
}
}
Ok(())
}
pub(crate) async fn delete_reset_handles(
&self,
handles: Vec<String>,
) -> Result<(), VnidropError> {
for handle in handles {
self.delete_if_present(&SecretHandle::from_stored(handle))
.await?;
}
Ok(())
}
pub(crate) async fn protect( pub(crate) async fn protect(
&self, &self,
kind: SecretKind, kind: SecretKind,
@@ -872,6 +930,23 @@ impl FaultInjectingSecretStore {
handles.into_iter().next().unwrap() handles.into_iter().next().unwrap()
} }
pub(crate) fn endpoint_identity_handle_for_test(&self) -> SecretHandle {
let handles = self
.values
.lock()
.unwrap()
.keys()
.filter(|handle| handle.as_str().contains("/endpoint-identity/"))
.cloned()
.collect::<Vec<_>>();
assert_eq!(
handles.len(),
1,
"expected exactly one protected endpoint identity"
);
handles.into_iter().next().unwrap()
}
fn check_available(&self) -> Result<(), SecureSecretStoreError> { fn check_available(&self) -> Result<(), SecureSecretStoreError> {
match *self.failure.lock().unwrap() { match *self.failure.lock().unwrap() {
Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked), Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked),

View File

@@ -62,6 +62,7 @@ fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_point
"fn share_files(", "fn share_files(",
"fn receive(", "fn receive(",
"saved_device_capabilities", "saved_device_capabilities",
"fn reset_unrecoverable_identity_with_limits_and_network_config(",
] { ] {
assert!( assert!(
facade.contains(required) || api.contains(required) || lib.contains(required), facade.contains(required) || api.contains(required) || lib.contains(required),

View File

@@ -682,6 +682,73 @@ fn saved_remote_name_and_local_label_survive_restart() {
restarted.shutdown(); restarted.shutdown();
} }
#[test]
fn explicit_identity_reset_recovers_missing_credential_without_silent_replacement() {
let alice = ProtectedNode::new();
let bob = ProtectedNode::new();
reach_saved(&alice, &bob, 90_063);
let original_endpoint_id = alice.core.status().endpoint_id;
let invitation_history = alice
.core
.list_transfers()
.unwrap()
.into_iter()
.map(|transfer| (transfer.local_id, transfer.transfer_name))
.collect::<Vec<_>>();
assert!(!alice.core.list_saved_devices().unwrap().is_empty());
alice.core.shutdown();
let endpoint_handle = alice.store.endpoint_identity_handle_for_test();
alice.store.remove_for_test(&endpoint_handle);
let app_data_dir = alice._data_dir.path().to_string_lossy().into_owned();
assert!(matches!(
VnidropCore::initialize_with_test_secret_store(
app_data_dir.clone(),
Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
}),
alice.store.clone(),
),
Err(crate::VnidropError::SecureStorageMissing { .. })
));
let recovered = VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
app_data_dir,
Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
}),
alice.store.clone(),
)
.unwrap();
assert_ne!(recovered.status().endpoint_id, original_endpoint_id);
assert!(recovered.list_saved_devices().unwrap().is_empty());
assert!(recovered.list_device_relationships().unwrap().is_empty());
assert!(recovered.list_pairing_eligibilities().unwrap().is_empty());
let recovered_history = recovered.list_transfers().unwrap();
assert_eq!(recovered_history.len(), invitation_history.len());
assert_eq!(recovered_history[0].status, "stopped");
assert_eq!(
(
recovered_history[0].local_id.clone(),
recovered_history[0].transfer_name.clone(),
),
invitation_history[0]
);
recovered.shutdown();
assert!(matches!(
VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
alice._data_dir.path().to_string_lossy().into_owned(),
Arc::new(RecordingSink {
events: Mutex::new(Vec::new()),
}),
alice.store.clone(),
),
Err(crate::VnidropError::InvalidInput { .. })
));
}
#[test] #[test]
fn events_carry_stable_ids_and_monotonic_revisions() { fn events_carry_stable_ids_and_monotonic_revisions() {
let alice = ProtectedNode::new(); let alice = ProtectedNode::new();

View File

@@ -847,6 +847,10 @@ fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() {
source.contains("initializeWithLimitsAndNetworkConfig"), source.contains("initializeWithLimitsAndNetworkConfig"),
"Swift bindings must expose standard protected initialization" "Swift bindings must expose standard protected initialization"
); );
assert!(
source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"),
"Swift bindings must expose explicit endpoint-identity recovery"
);
assert!( assert!(
source.contains("public struct SavedDeviceCapabilities") source.contains("public struct SavedDeviceCapabilities")
&& source.contains("public func savedDeviceCapabilities()"), && source.contains("public func savedDeviceCapabilities()"),

View File

@@ -338,6 +338,57 @@ fn create_targeted_transfer_is_immutable_and_saved_only() {
assert_eq!(listed.total_size, transfer.total_size); assert_eq!(listed.total_size, transfer.total_size);
} }
#[test]
fn identity_reset_cancels_targeted_authorization_bound_to_the_lost_endpoint() {
let mut alice = ProtectedNode::new();
let bob = ProtectedNode::new();
let bob_id = bob.core().status().endpoint_id;
establish_saved(&alice, &bob, 10_002);
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("identity-bound.txt");
std::fs::write(&source_path, b"identity-bound authorization").unwrap();
let bob_core = bob.core();
let accept = std::thread::spawn(move || {
let offer = wait_for_pending_offer(&bob_core);
bob_core
.respond_to_targeted_offer(offer.transfer_id, true)
.unwrap()
});
let transfer = alice
.core()
.create_targeted_transfer(
bob_id,
vec![targeted_source(&source_path)],
Some("identity-bound.txt".to_string()),
)
.unwrap();
assert!(matches!(
accept.join().unwrap(),
crate::TargetedOfferResponse::Approved { .. }
));
alice.core.take().unwrap().shutdown();
let endpoint_handle = alice.secret_store.endpoint_identity_handle_for_test();
alice.secret_store.remove_for_test(&endpoint_handle);
let recovered = VnidropCore::reset_unrecoverable_identity_with_test_secret_store(
alice.data_dir.path().to_string_lossy().into_owned(),
alice.sink.clone(),
alice.secret_store.clone(),
)
.unwrap();
let snapshot = recovered
.get_targeted_transfer(transfer.id.clone())
.unwrap()
.unwrap();
assert_eq!(snapshot.state, TargetedTransferState::Cancelled);
assert!(recovered
.targeted_blob_ticket_for_test(transfer.id)
.is_err());
assert!(recovered.list_saved_devices().unwrap().is_empty());
recovered.shutdown();
}
#[test] #[test]
fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() { fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() {
let alice = ProtectedNode::new(); let alice = ProtectedNode::new();

View File

@@ -56,6 +56,26 @@ regenerate. Regenerated output is deterministic (sorted keys), so diffs stay sma
- **`plural`** — per language, per [CLDR category](https://cldr.unicode.org/index/cldr-spec/plural-rules) - **`plural`** — per language, per [CLDR category](https://cldr.unicode.org/index/cldr-spec/plural-rules)
(`zero`, `one`, `two`, `few`, `many`, `other`). `other` is always required. (`zero`, `one`, `two`, `few`, `many`, `other`). `other` is always required.
### Every language gets a real translation
**Never copy the English text into another language as a placeholder.** A key is not done
until every language in `supportedLanguages` has text actually written in that language.
Copied English does not look unfinished — it looks shipped. Nothing flags it, because the
key is present and non-empty, so it passes `validate` and reaches users as a French or
Russian build that silently speaks English. It is far harder to find later than a missing
key would have been.
If you cannot produce a translation, say so in the PR and leave the key out of the release
rather than filling it with English. Two narrow exceptions, both of which must be obvious
from the text itself:
- Pure punctuation or layout templates with no words (`"{first} · {second}"`).
- Proper nouns and brand names that are identical in every language.
When adding a key next to existing ones, check that the neighbours are translated before
copying their shape — a placeholder tends to be copied into the keys added after it.
### Placeholders ### Placeholders
Write named tokens `{count}`, `{name}` in text. The generator converts them to the right Write named tokens `{count}`, `{name}` in text. The generator converts them to the right

File diff suppressed because it is too large Load Diff

View File

@@ -265,6 +265,8 @@
<string name="storage_total">Gesamt</string> <string name="storage_total">Gesamt</string>
<string name="storage_transfer_data">Übertragungsdaten</string> <string name="storage_transfer_data">Übertragungsdaten</string>
<string name="storage_transfers_deleted">Alle Übertragungen gelöscht</string> <string name="storage_transfers_deleted">Alle Übertragungen gelöscht</string>
<string name="targeted_offer_body">%1$s möchte Ihnen „%2$s“ senden.</string>
<string name="targeted_offer_title">Eingehende Übertragung</string>
<string name="transfer_activity_description">Wichtige Aktualisierungen zu dieser Übertragung ansehen</string> <string name="transfer_activity_description">Wichtige Aktualisierungen zu dieser Übertragung ansehen</string>
<string name="transfer_activity_title">Aktivität</string> <string name="transfer_activity_title">Aktivität</string>
<string name="transfer_delete_description">„%1$s“ wird nicht mehr geteilt und sein Übertragungsverlauf wird von diesem Gerät entfernt.</string> <string name="transfer_delete_description">„%1$s“ wird nicht mehr geteilt und sein Übertragungsverlauf wird von diesem Gerät entfernt.</string>
@@ -345,31 +347,31 @@
<string name="saved_devices_empty_title">Keine gespeicherten Geräte</string> <string name="saved_devices_empty_title">Keine gespeicherten Geräte</string>
<string name="saved_devices_forget_confirm_body">%1$s vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen.</string> <string name="saved_devices_forget_confirm_body">%1$s vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen.</string>
<string name="saved_devices_forget_confirm_title">Gerät vergessen?</string> <string name="saved_devices_forget_confirm_title">Gerät vergessen?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Bereit zum Speichern</string>
<string name="saved_devices_pending_title">Pending pairing</string> <string name="saved_devices_pending_title">Ausstehende Kopplung</string>
<string name="saved_devices_list_title">Saved devices</string> <string name="saved_devices_list_title">Gespeicherte Geräte</string>
<string name="saved_devices_load_failed">Gespeicherte Geräte konnten nicht geladen werden.</string> <string name="saved_devices_load_failed">Gespeicherte Geräte konnten nicht geladen werden.</string>
<string name="saved_devices_loading">Gespeicherte Geräte werden geladen…</string> <string name="saved_devices_loading">Gespeicherte Geräte werden geladen…</string>
<string name="saved_devices_more_actions">Weitere Aktionen für %1$s</string> <string name="saved_devices_more_actions">Weitere Aktionen für %1$s</string>
<string name="saved_devices_no_pending">Keine Kopplungsanfragen benötigen Ihre Aufmerksamkeit.</string> <string name="saved_devices_no_pending">Keine Kopplungsanfragen benötigen Ihre Aufmerksamkeit.</string>
<string name="saved_devices_unnamed">Saved device</string> <string name="saved_devices_unnamed">Gespeichertes Gerät</string>
<string name="saved_devices_remember_action">Remember</string> <string name="saved_devices_remember_action">Speichern</string>
<string name="saved_devices_decline_action">Decline</string> <string name="saved_devices_decline_action">Ablehnen</string>
<string name="saved_devices_accept_pairing_action">Accept</string> <string name="saved_devices_accept_pairing_action">Annehmen</string>
<string name="saved_devices_send_action">Send files</string> <string name="saved_devices_send_action">Dateien senden</string>
<string name="saved_devices_label_action">Label</string> <string name="saved_devices_label_action">Bezeichnung</string>
<string name="saved_devices_forget_action">Forget</string> <string name="saved_devices_forget_action">Vergessen</string>
<string name="saved_devices_block_action">Block</string> <string name="saved_devices_block_action">Blockieren</string>
<string name="saved_devices_label_title">Device label</string> <string name="saved_devices_label_title">Gerätebezeichnung</string>
<string name="saved_devices_label_placeholder">Label</string> <string name="saved_devices_label_placeholder">Bezeichnung</string>
<string name="saved_devices_label_save">Save</string> <string name="saved_devices_label_save">Speichern</string>
<string name="saved_devices_label_clear">Clear label</string> <string name="saved_devices_label_clear">Bezeichnung löschen</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string> <string name="saved_devices_pending_outgoing">Warten auf das andere Gerät</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string> <string name="saved_devices_pending_incoming">Möchte dieses Gerät speichern</string>
<string name="saved_devices_send_started">Transfer offer sent</string> <string name="saved_devices_send_started">Übertragungsangebot gesendet</string>
<string name="saved_devices_forgotten">Saved device forgotten</string> <string name="saved_devices_forgotten">Gespeichertes Gerät vergessen</string>
<string name="saved_devices_blocked">Device blocked</string> <string name="saved_devices_blocked">Gerät blockiert</string>
<string name="saved_devices_labeled">Label updated</string> <string name="saved_devices_labeled">Bezeichnung aktualisiert</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d Datei</item> <item quantity="one">%1$d Datei</item>
<item quantity="other">%1$d Dateien</item> <item quantity="other">%1$d Dateien</item>

View File

@@ -265,6 +265,8 @@
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Datos de transferencia</string> <string name="storage_transfer_data">Datos de transferencia</string>
<string name="storage_transfers_deleted">Todas las transferencias eliminadas</string> <string name="storage_transfers_deleted">Todas las transferencias eliminadas</string>
<string name="targeted_offer_body">%1$s quiere enviarle “%2$s”.</string>
<string name="targeted_offer_title">Transferencia entrante</string>
<string name="transfer_activity_description">Vea las actualizaciones importantes de esta transferencia</string> <string name="transfer_activity_description">Vea las actualizaciones importantes de esta transferencia</string>
<string name="transfer_activity_title">Actividad</string> <string name="transfer_activity_title">Actividad</string>
<string name="transfer_delete_description">«%1$s» dejará de compartirse y su historial de transferencia se eliminará de este dispositivo.</string> <string name="transfer_delete_description">«%1$s» dejará de compartirse y su historial de transferencia se eliminará de este dispositivo.</string>
@@ -345,31 +347,31 @@
<string name="saved_devices_empty_title">No hay dispositivos guardados</string> <string name="saved_devices_empty_title">No hay dispositivos guardados</string>
<string name="saved_devices_forget_confirm_body">¿Olvidar a %1$s? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa.</string> <string name="saved_devices_forget_confirm_body">¿Olvidar a %1$s? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa.</string>
<string name="saved_devices_forget_confirm_title">¿Olvidar dispositivo?</string> <string name="saved_devices_forget_confirm_title">¿Olvidar dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Listo para recordar</string>
<string name="saved_devices_pending_title">Pending pairing</string> <string name="saved_devices_pending_title">Vinculación pendiente</string>
<string name="saved_devices_list_title">Saved devices</string> <string name="saved_devices_list_title">Dispositivos guardados</string>
<string name="saved_devices_load_failed">No se pudieron cargar los dispositivos guardados.</string> <string name="saved_devices_load_failed">No se pudieron cargar los dispositivos guardados.</string>
<string name="saved_devices_loading">Cargando dispositivos guardados…</string> <string name="saved_devices_loading">Cargando dispositivos guardados…</string>
<string name="saved_devices_more_actions">Más acciones para %1$s</string> <string name="saved_devices_more_actions">Más acciones para %1$s</string>
<string name="saved_devices_no_pending">No hay solicitudes de vinculación que requieran tu atención.</string> <string name="saved_devices_no_pending">No hay solicitudes de vinculación que requieran tu atención.</string>
<string name="saved_devices_unnamed">Saved device</string> <string name="saved_devices_unnamed">Dispositivo guardado</string>
<string name="saved_devices_remember_action">Remember</string> <string name="saved_devices_remember_action">Recordar</string>
<string name="saved_devices_decline_action">Decline</string> <string name="saved_devices_decline_action">Rechazar</string>
<string name="saved_devices_accept_pairing_action">Accept</string> <string name="saved_devices_accept_pairing_action">Aceptar</string>
<string name="saved_devices_send_action">Send files</string> <string name="saved_devices_send_action">Enviar archivos</string>
<string name="saved_devices_label_action">Label</string> <string name="saved_devices_label_action">Etiqueta</string>
<string name="saved_devices_forget_action">Forget</string> <string name="saved_devices_forget_action">Olvidar</string>
<string name="saved_devices_block_action">Block</string> <string name="saved_devices_block_action">Bloquear</string>
<string name="saved_devices_label_title">Device label</string> <string name="saved_devices_label_title">Etiqueta del dispositivo</string>
<string name="saved_devices_label_placeholder">Label</string> <string name="saved_devices_label_placeholder">Etiqueta</string>
<string name="saved_devices_label_save">Save</string> <string name="saved_devices_label_save">Guardar</string>
<string name="saved_devices_label_clear">Clear label</string> <string name="saved_devices_label_clear">Borrar etiqueta</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string> <string name="saved_devices_pending_outgoing">Esperando al otro dispositivo</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string> <string name="saved_devices_pending_incoming">Quiere recordar este dispositivo</string>
<string name="saved_devices_send_started">Transfer offer sent</string> <string name="saved_devices_send_started">Oferta de transferencia enviada</string>
<string name="saved_devices_forgotten">Saved device forgotten</string> <string name="saved_devices_forgotten">Dispositivo guardado olvidado</string>
<string name="saved_devices_blocked">Device blocked</string> <string name="saved_devices_blocked">Dispositivo bloqueado</string>
<string name="saved_devices_labeled">Label updated</string> <string name="saved_devices_labeled">Etiqueta actualizada</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d archivo</item> <item quantity="one">%1$d archivo</item>
<item quantity="other">%1$d archivos</item> <item quantity="other">%1$d archivos</item>

View File

@@ -265,6 +265,8 @@
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Données de transfert</string> <string name="storage_transfer_data">Données de transfert</string>
<string name="storage_transfers_deleted">Tous les transferts supprimés</string> <string name="storage_transfers_deleted">Tous les transferts supprimés</string>
<string name="targeted_offer_body">%1$s veut vous envoyer « %2$s».</string>
<string name="targeted_offer_title">Transfert entrant</string>
<string name="transfer_activity_description">Consultez les mises à jour importantes de ce transfert</string> <string name="transfer_activity_description">Consultez les mises à jour importantes de ce transfert</string>
<string name="transfer_activity_title">Activité</string> <string name="transfer_activity_title">Activité</string>
<string name="transfer_delete_description">« %1$s » cessera dêtre partagé et son historique de transfert sera retiré de cet appareil.</string> <string name="transfer_delete_description">« %1$s » cessera dêtre partagé et son historique de transfert sera retiré de cet appareil.</string>
@@ -345,31 +347,31 @@
<string name="saved_devices_empty_title">Aucun appareil enregistré</string> <string name="saved_devices_empty_title">Aucun appareil enregistré</string>
<string name="saved_devices_forget_confirm_body">Oublier %1$s ? Vous devrez tous les deux approuver à nouveau lenregistrement avant un autre transfert direct.</string> <string name="saved_devices_forget_confirm_body">Oublier %1$s ? Vous devrez tous les deux approuver à nouveau lenregistrement avant un autre transfert direct.</string>
<string name="saved_devices_forget_confirm_title">Oublier lappareil ?</string> <string name="saved_devices_forget_confirm_title">Oublier lappareil ?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Prêt à être mémorisé</string>
<string name="saved_devices_pending_title">Pending pairing</string> <string name="saved_devices_pending_title">Association en attente</string>
<string name="saved_devices_list_title">Saved devices</string> <string name="saved_devices_list_title">Appareils enregistrés</string>
<string name="saved_devices_load_failed">Impossible de charger les appareils enregistrés.</string> <string name="saved_devices_load_failed">Impossible de charger les appareils enregistrés.</string>
<string name="saved_devices_loading">Chargement des appareils enregistrés…</string> <string name="saved_devices_loading">Chargement des appareils enregistrés…</string>
<string name="saved_devices_more_actions">Plus dactions pour %1$s</string> <string name="saved_devices_more_actions">Plus dactions pour %1$s</string>
<string name="saved_devices_no_pending">Aucune demande dassociation ne nécessite votre attention.</string> <string name="saved_devices_no_pending">Aucune demande dassociation ne nécessite votre attention.</string>
<string name="saved_devices_unnamed">Saved device</string> <string name="saved_devices_unnamed">Appareil enregistré</string>
<string name="saved_devices_remember_action">Remember</string> <string name="saved_devices_remember_action">Mémoriser</string>
<string name="saved_devices_decline_action">Decline</string> <string name="saved_devices_decline_action">Refuser</string>
<string name="saved_devices_accept_pairing_action">Accept</string> <string name="saved_devices_accept_pairing_action">Accepter</string>
<string name="saved_devices_send_action">Send files</string> <string name="saved_devices_send_action">Envoyer des fichiers</string>
<string name="saved_devices_label_action">Label</string> <string name="saved_devices_label_action">Libel</string>
<string name="saved_devices_forget_action">Forget</string> <string name="saved_devices_forget_action">Oublier</string>
<string name="saved_devices_block_action">Block</string> <string name="saved_devices_block_action">Bloquer</string>
<string name="saved_devices_label_title">Device label</string> <string name="saved_devices_label_title">Libellé de lappareil</string>
<string name="saved_devices_label_placeholder">Label</string> <string name="saved_devices_label_placeholder">Libel</string>
<string name="saved_devices_label_save">Save</string> <string name="saved_devices_label_save">Enregistrer</string>
<string name="saved_devices_label_clear">Clear label</string> <string name="saved_devices_label_clear">Effacer le libel</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string> <string name="saved_devices_pending_outgoing">En attente de lautre appareil</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string> <string name="saved_devices_pending_incoming">Souhaite mémoriser cet appareil</string>
<string name="saved_devices_send_started">Transfer offer sent</string> <string name="saved_devices_send_started">Proposition de transfert envoyée</string>
<string name="saved_devices_forgotten">Saved device forgotten</string> <string name="saved_devices_forgotten">Appareil enregistré oublié</string>
<string name="saved_devices_blocked">Device blocked</string> <string name="saved_devices_blocked">Appareil bloqué</string>
<string name="saved_devices_labeled">Label updated</string> <string name="saved_devices_labeled">Libellé mis à jour</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d fichier</item> <item quantity="one">%1$d fichier</item>
<item quantity="other">%1$d fichiers</item> <item quantity="other">%1$d fichiers</item>

View File

@@ -265,6 +265,8 @@
<string name="storage_total">Totale</string> <string name="storage_total">Totale</string>
<string name="storage_transfer_data">Dati di trasferimento</string> <string name="storage_transfer_data">Dati di trasferimento</string>
<string name="storage_transfers_deleted">Tutti i trasferimenti eliminati</string> <string name="storage_transfers_deleted">Tutti i trasferimenti eliminati</string>
<string name="targeted_offer_body">%1$s vuole inviarle “%2$s”.</string>
<string name="targeted_offer_title">Trasferimento in arrivo</string>
<string name="transfer_activity_description">Veda gli aggiornamenti importanti di questo trasferimento</string> <string name="transfer_activity_description">Veda gli aggiornamenti importanti di questo trasferimento</string>
<string name="transfer_activity_title">Attività</string> <string name="transfer_activity_title">Attività</string>
<string name="transfer_delete_description">«%1$s» non verrà più condiviso e la sua cronologia di trasferimento verrà rimossa da questo dispositivo.</string> <string name="transfer_delete_description">«%1$s» non verrà più condiviso e la sua cronologia di trasferimento verrà rimossa da questo dispositivo.</string>
@@ -345,31 +347,31 @@
<string name="saved_devices_empty_title">Nessun dispositivo salvato</string> <string name="saved_devices_empty_title">Nessun dispositivo salvato</string>
<string name="saved_devices_forget_confirm_body">Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.</string> <string name="saved_devices_forget_confirm_body">Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.</string>
<string name="saved_devices_forget_confirm_title">Dimenticare il dispositivo?</string> <string name="saved_devices_forget_confirm_title">Dimenticare il dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Pronto da ricordare</string>
<string name="saved_devices_pending_title">Pending pairing</string> <string name="saved_devices_pending_title">Associazione in sospeso</string>
<string name="saved_devices_list_title">Saved devices</string> <string name="saved_devices_list_title">Dispositivi salvati</string>
<string name="saved_devices_load_failed">Impossibile caricare i dispositivi salvati.</string> <string name="saved_devices_load_failed">Impossibile caricare i dispositivi salvati.</string>
<string name="saved_devices_loading">Caricamento dei dispositivi salvati…</string> <string name="saved_devices_loading">Caricamento dei dispositivi salvati…</string>
<string name="saved_devices_more_actions">Altre azioni per %1$s</string> <string name="saved_devices_more_actions">Altre azioni per %1$s</string>
<string name="saved_devices_no_pending">Nessuna richiesta di associazione richiede attenzione.</string> <string name="saved_devices_no_pending">Nessuna richiesta di associazione richiede attenzione.</string>
<string name="saved_devices_unnamed">Saved device</string> <string name="saved_devices_unnamed">Dispositivo salvato</string>
<string name="saved_devices_remember_action">Remember</string> <string name="saved_devices_remember_action">Ricorda</string>
<string name="saved_devices_decline_action">Decline</string> <string name="saved_devices_decline_action">Rifiuta</string>
<string name="saved_devices_accept_pairing_action">Accept</string> <string name="saved_devices_accept_pairing_action">Accetta</string>
<string name="saved_devices_send_action">Send files</string> <string name="saved_devices_send_action">Invia file</string>
<string name="saved_devices_label_action">Label</string> <string name="saved_devices_label_action">Etichetta</string>
<string name="saved_devices_forget_action">Forget</string> <string name="saved_devices_forget_action">Dimentica</string>
<string name="saved_devices_block_action">Block</string> <string name="saved_devices_block_action">Blocca</string>
<string name="saved_devices_label_title">Device label</string> <string name="saved_devices_label_title">Etichetta del dispositivo</string>
<string name="saved_devices_label_placeholder">Label</string> <string name="saved_devices_label_placeholder">Etichetta</string>
<string name="saved_devices_label_save">Save</string> <string name="saved_devices_label_save">Salva</string>
<string name="saved_devices_label_clear">Clear label</string> <string name="saved_devices_label_clear">Cancella etichetta</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string> <string name="saved_devices_pending_outgoing">In attesa dellaltro dispositivo</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string> <string name="saved_devices_pending_incoming">Vuole ricordare questo dispositivo</string>
<string name="saved_devices_send_started">Transfer offer sent</string> <string name="saved_devices_send_started">Proposta di trasferimento inviata</string>
<string name="saved_devices_forgotten">Saved device forgotten</string> <string name="saved_devices_forgotten">Dispositivo salvato dimenticato</string>
<string name="saved_devices_blocked">Device blocked</string> <string name="saved_devices_blocked">Dispositivo bloccato</string>
<string name="saved_devices_labeled">Label updated</string> <string name="saved_devices_labeled">Etichetta aggiornata</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d file</item> <item quantity="one">%1$d file</item>
<item quantity="other">%1$d file</item> <item quantity="other">%1$d file</item>

View File

@@ -265,6 +265,8 @@
<string name="storage_total">Totaal</string> <string name="storage_total">Totaal</string>
<string name="storage_transfer_data">Overdrachtsgegevens</string> <string name="storage_transfer_data">Overdrachtsgegevens</string>
<string name="storage_transfers_deleted">Alle overdrachten verwijderd</string> <string name="storage_transfers_deleted">Alle overdrachten verwijderd</string>
<string name="targeted_offer_body">%1$s wil u “%2$s” sturen.</string>
<string name="targeted_offer_title">Inkomende overdracht</string>
<string name="transfer_activity_description">Bekijk belangrijke updates voor deze overdracht</string> <string name="transfer_activity_description">Bekijk belangrijke updates voor deze overdracht</string>
<string name="transfer_activity_title">Activiteit</string> <string name="transfer_activity_title">Activiteit</string>
<string name="transfer_delete_description">%1$s wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd.</string> <string name="transfer_delete_description">%1$s wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd.</string>
@@ -345,31 +347,31 @@
<string name="saved_devices_empty_title">Geen opgeslagen apparaten</string> <string name="saved_devices_empty_title">Geen opgeslagen apparaten</string>
<string name="saved_devices_forget_confirm_body">%1$s vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht.</string> <string name="saved_devices_forget_confirm_body">%1$s vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht.</string>
<string name="saved_devices_forget_confirm_title">Apparaat vergeten?</string> <string name="saved_devices_forget_confirm_title">Apparaat vergeten?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Klaar om te onthouden</string>
<string name="saved_devices_pending_title">Pending pairing</string> <string name="saved_devices_pending_title">Koppeling in behandeling</string>
<string name="saved_devices_list_title">Saved devices</string> <string name="saved_devices_list_title">Opgeslagen apparaten</string>
<string name="saved_devices_load_failed">Opgeslagen apparaten konden niet worden geladen.</string> <string name="saved_devices_load_failed">Opgeslagen apparaten konden niet worden geladen.</string>
<string name="saved_devices_loading">Opgeslagen apparaten laden…</string> <string name="saved_devices_loading">Opgeslagen apparaten laden…</string>
<string name="saved_devices_more_actions">Meer acties voor %1$s</string> <string name="saved_devices_more_actions">Meer acties voor %1$s</string>
<string name="saved_devices_no_pending">Er zijn geen koppelverzoeken die aandacht nodig hebben.</string> <string name="saved_devices_no_pending">Er zijn geen koppelverzoeken die aandacht nodig hebben.</string>
<string name="saved_devices_unnamed">Saved device</string> <string name="saved_devices_unnamed">Opgeslagen apparaat</string>
<string name="saved_devices_remember_action">Remember</string> <string name="saved_devices_remember_action">Onthouden</string>
<string name="saved_devices_decline_action">Decline</string> <string name="saved_devices_decline_action">Weigeren</string>
<string name="saved_devices_accept_pairing_action">Accept</string> <string name="saved_devices_accept_pairing_action">Accepteren</string>
<string name="saved_devices_send_action">Send files</string> <string name="saved_devices_send_action">Bestanden versturen</string>
<string name="saved_devices_label_action">Label</string> <string name="saved_devices_label_action">Label</string>
<string name="saved_devices_forget_action">Forget</string> <string name="saved_devices_forget_action">Vergeten</string>
<string name="saved_devices_block_action">Block</string> <string name="saved_devices_block_action">Blokkeren</string>
<string name="saved_devices_label_title">Device label</string> <string name="saved_devices_label_title">Apparaatlabel</string>
<string name="saved_devices_label_placeholder">Label</string> <string name="saved_devices_label_placeholder">Label</string>
<string name="saved_devices_label_save">Save</string> <string name="saved_devices_label_save">Opslaan</string>
<string name="saved_devices_label_clear">Clear label</string> <string name="saved_devices_label_clear">Label wissen</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string> <string name="saved_devices_pending_outgoing">Wachten op het andere apparaat</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string> <string name="saved_devices_pending_incoming">Wil dit apparaat onthouden</string>
<string name="saved_devices_send_started">Transfer offer sent</string> <string name="saved_devices_send_started">Overdrachtsaanbod verstuurd</string>
<string name="saved_devices_forgotten">Saved device forgotten</string> <string name="saved_devices_forgotten">Opgeslagen apparaat vergeten</string>
<string name="saved_devices_blocked">Device blocked</string> <string name="saved_devices_blocked">Apparaat geblokkeerd</string>
<string name="saved_devices_labeled">Label updated</string> <string name="saved_devices_labeled">Label bijgewerkt</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d bestand</item> <item quantity="one">%1$d bestand</item>
<item quantity="other">%1$d bestanden</item> <item quantity="other">%1$d bestanden</item>

View File

@@ -265,6 +265,8 @@
<string name="storage_total">Łącznie</string> <string name="storage_total">Łącznie</string>
<string name="storage_transfer_data">Dane transferu</string> <string name="storage_transfer_data">Dane transferu</string>
<string name="storage_transfers_deleted">Usunięto wszystkie transfery</string> <string name="storage_transfers_deleted">Usunięto wszystkie transfery</string>
<string name="targeted_offer_body">%1$s chce wysłać Ci „%2$s”.</string>
<string name="targeted_offer_title">Transfer przychodzący</string>
<string name="transfer_activity_description">Zobacz ważne aktualizacje tego transferu</string> <string name="transfer_activity_description">Zobacz ważne aktualizacje tego transferu</string>
<string name="transfer_activity_title">Aktywność</string> <string name="transfer_activity_title">Aktywność</string>
<string name="transfer_delete_description">„%1$s” przestanie być udostępniany, a jego historia transferu zostanie usunięta z tego urządzenia.</string> <string name="transfer_delete_description">„%1$s” przestanie być udostępniany, a jego historia transferu zostanie usunięta z tego urządzenia.</string>
@@ -345,31 +347,31 @@
<string name="saved_devices_empty_title">Brak zapisanych urządzeń</string> <string name="saved_devices_empty_title">Brak zapisanych urządzeń</string>
<string name="saved_devices_forget_confirm_body">Zapomnieć %1$s? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie.</string> <string name="saved_devices_forget_confirm_body">Zapomnieć %1$s? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie.</string>
<string name="saved_devices_forget_confirm_title">Zapomnieć urządzenie?</string> <string name="saved_devices_forget_confirm_title">Zapomnieć urządzenie?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Gotowe do zapamiętania</string>
<string name="saved_devices_pending_title">Pending pairing</string> <string name="saved_devices_pending_title">Oczekujące sparowanie</string>
<string name="saved_devices_list_title">Saved devices</string> <string name="saved_devices_list_title">Zapisane urządzenia</string>
<string name="saved_devices_load_failed">Nie udało się wczytać zapisanych urządzeń.</string> <string name="saved_devices_load_failed">Nie udało się wczytać zapisanych urządzeń.</string>
<string name="saved_devices_loading">Wczytywanie zapisanych urządzeń…</string> <string name="saved_devices_loading">Wczytywanie zapisanych urządzeń…</string>
<string name="saved_devices_more_actions">Więcej działań dla %1$s</string> <string name="saved_devices_more_actions">Więcej działań dla %1$s</string>
<string name="saved_devices_no_pending">Żadne prośby o sparowanie nie wymagają uwagi.</string> <string name="saved_devices_no_pending">Żadne prośby o sparowanie nie wymagają uwagi.</string>
<string name="saved_devices_unnamed">Saved device</string> <string name="saved_devices_unnamed">Zapisane urządzenie</string>
<string name="saved_devices_remember_action">Remember</string> <string name="saved_devices_remember_action">Zapamiętaj</string>
<string name="saved_devices_decline_action">Decline</string> <string name="saved_devices_decline_action">Odrzuć</string>
<string name="saved_devices_accept_pairing_action">Accept</string> <string name="saved_devices_accept_pairing_action">Akceptuj</string>
<string name="saved_devices_send_action">Send files</string> <string name="saved_devices_send_action">Wyślij pliki</string>
<string name="saved_devices_label_action">Label</string> <string name="saved_devices_label_action">Etykieta</string>
<string name="saved_devices_forget_action">Forget</string> <string name="saved_devices_forget_action">Zapomnij</string>
<string name="saved_devices_block_action">Block</string> <string name="saved_devices_block_action">Zablokuj</string>
<string name="saved_devices_label_title">Device label</string> <string name="saved_devices_label_title">Etykieta urządzenia</string>
<string name="saved_devices_label_placeholder">Label</string> <string name="saved_devices_label_placeholder">Etykieta</string>
<string name="saved_devices_label_save">Save</string> <string name="saved_devices_label_save">Zapisz</string>
<string name="saved_devices_label_clear">Clear label</string> <string name="saved_devices_label_clear">Wyczyść etykietę</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string> <string name="saved_devices_pending_outgoing">Oczekiwanie na drugie urządzenie</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string> <string name="saved_devices_pending_incoming">Chce zapamiętać to urządzenie</string>
<string name="saved_devices_send_started">Transfer offer sent</string> <string name="saved_devices_send_started">Wysłano propozycję transferu</string>
<string name="saved_devices_forgotten">Saved device forgotten</string> <string name="saved_devices_forgotten">Zapomniano zapisane urządzenie</string>
<string name="saved_devices_blocked">Device blocked</string> <string name="saved_devices_blocked">Urządzenie zablokowane</string>
<string name="saved_devices_labeled">Label updated</string> <string name="saved_devices_labeled">Zaktualizowano etykietę</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d plik</item> <item quantity="one">%1$d plik</item>
<item quantity="few">%1$d pliki</item> <item quantity="few">%1$d pliki</item>

View File

@@ -265,6 +265,8 @@
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Dados de transferência</string> <string name="storage_transfer_data">Dados de transferência</string>
<string name="storage_transfers_deleted">Todas as transferências eliminadas</string> <string name="storage_transfers_deleted">Todas as transferências eliminadas</string>
<string name="targeted_offer_body">%1$s quer enviar-lhe “%2$s”.</string>
<string name="targeted_offer_title">Transferência a receber</string>
<string name="transfer_activity_description">Ver as atualizações importantes desta transferência</string> <string name="transfer_activity_description">Ver as atualizações importantes desta transferência</string>
<string name="transfer_activity_title">Atividade</string> <string name="transfer_activity_title">Atividade</string>
<string name="transfer_delete_description">«%1$s» deixará de ser partilhado e o seu histórico de transferência será removido deste dispositivo.</string> <string name="transfer_delete_description">«%1$s» deixará de ser partilhado e o seu histórico de transferência será removido deste dispositivo.</string>
@@ -345,31 +347,31 @@
<string name="saved_devices_empty_title">Nenhum dispositivo guardado</string> <string name="saved_devices_empty_title">Nenhum dispositivo guardado</string>
<string name="saved_devices_forget_confirm_body">Esquecer %1$s? Ambos terão de voltar a aprovar antes de outra transferência direta.</string> <string name="saved_devices_forget_confirm_body">Esquecer %1$s? Ambos terão de voltar a aprovar antes de outra transferência direta.</string>
<string name="saved_devices_forget_confirm_title">Esquecer dispositivo?</string> <string name="saved_devices_forget_confirm_title">Esquecer dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Pronto para lembrar</string>
<string name="saved_devices_pending_title">Pending pairing</string> <string name="saved_devices_pending_title">Emparelhamento pendente</string>
<string name="saved_devices_list_title">Saved devices</string> <string name="saved_devices_list_title">Dispositivos guardados</string>
<string name="saved_devices_load_failed">Não foi possível carregar os dispositivos guardados.</string> <string name="saved_devices_load_failed">Não foi possível carregar os dispositivos guardados.</string>
<string name="saved_devices_loading">A carregar dispositivos guardados…</string> <string name="saved_devices_loading">A carregar dispositivos guardados…</string>
<string name="saved_devices_more_actions">Mais ações para %1$s</string> <string name="saved_devices_more_actions">Mais ações para %1$s</string>
<string name="saved_devices_no_pending">Não existem pedidos de emparelhamento a aguardar atenção.</string> <string name="saved_devices_no_pending">Não existem pedidos de emparelhamento a aguardar atenção.</string>
<string name="saved_devices_unnamed">Saved device</string> <string name="saved_devices_unnamed">Dispositivo guardado</string>
<string name="saved_devices_remember_action">Remember</string> <string name="saved_devices_remember_action">Lembrar</string>
<string name="saved_devices_decline_action">Decline</string> <string name="saved_devices_decline_action">Recusar</string>
<string name="saved_devices_accept_pairing_action">Accept</string> <string name="saved_devices_accept_pairing_action">Aceitar</string>
<string name="saved_devices_send_action">Send files</string> <string name="saved_devices_send_action">Enviar ficheiros</string>
<string name="saved_devices_label_action">Label</string> <string name="saved_devices_label_action">Etiqueta</string>
<string name="saved_devices_forget_action">Forget</string> <string name="saved_devices_forget_action">Esquecer</string>
<string name="saved_devices_block_action">Block</string> <string name="saved_devices_block_action">Bloquear</string>
<string name="saved_devices_label_title">Device label</string> <string name="saved_devices_label_title">Etiqueta do dispositivo</string>
<string name="saved_devices_label_placeholder">Label</string> <string name="saved_devices_label_placeholder">Etiqueta</string>
<string name="saved_devices_label_save">Save</string> <string name="saved_devices_label_save">Guardar</string>
<string name="saved_devices_label_clear">Clear label</string> <string name="saved_devices_label_clear">Limpar etiqueta</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string> <string name="saved_devices_pending_outgoing">A aguardar o outro dispositivo</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string> <string name="saved_devices_pending_incoming">Quer lembrar este dispositivo</string>
<string name="saved_devices_send_started">Transfer offer sent</string> <string name="saved_devices_send_started">Proposta de transferência enviada</string>
<string name="saved_devices_forgotten">Saved device forgotten</string> <string name="saved_devices_forgotten">Dispositivo guardado esquecido</string>
<string name="saved_devices_blocked">Device blocked</string> <string name="saved_devices_blocked">Dispositivo bloqueado</string>
<string name="saved_devices_labeled">Label updated</string> <string name="saved_devices_labeled">Etiqueta atualizada</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d ficheiro</item> <item quantity="one">%1$d ficheiro</item>
<item quantity="other">%1$d ficheiros</item> <item quantity="other">%1$d ficheiros</item>

View File

@@ -265,6 +265,8 @@
<string name="storage_total">Всего</string> <string name="storage_total">Всего</string>
<string name="storage_transfer_data">Данные передачи</string> <string name="storage_transfer_data">Данные передачи</string>
<string name="storage_transfers_deleted">Все передачи удалены</string> <string name="storage_transfers_deleted">Все передачи удалены</string>
<string name="targeted_offer_body">%1$s хочет отправить вам «%2$s».</string>
<string name="targeted_offer_title">Входящая передача</string>
<string name="transfer_activity_description">Просматривайте важные обновления этой передачи</string> <string name="transfer_activity_description">Просматривайте важные обновления этой передачи</string>
<string name="transfer_activity_title">Активность</string> <string name="transfer_activity_title">Активность</string>
<string name="transfer_delete_description">Общий доступ к «%1$s» будет остановлен, а история передачи будет удалена с этого устройства.</string> <string name="transfer_delete_description">Общий доступ к «%1$s» будет остановлен, а история передачи будет удалена с этого устройства.</string>
@@ -345,31 +347,31 @@
<string name="saved_devices_empty_title">Нет сохранённых устройств</string> <string name="saved_devices_empty_title">Нет сохранённых устройств</string>
<string name="saved_devices_forget_confirm_body">Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны.</string> <string name="saved_devices_forget_confirm_body">Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны.</string>
<string name="saved_devices_forget_confirm_title">Забыть устройство?</string> <string name="saved_devices_forget_confirm_title">Забыть устройство?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string> <string name="saved_devices_eligibility_title">Готово к запоминанию</string>
<string name="saved_devices_pending_title">Pending pairing</string> <string name="saved_devices_pending_title">Ожидающее сопряжение</string>
<string name="saved_devices_list_title">Saved devices</string> <string name="saved_devices_list_title">Сохранённые устройства</string>
<string name="saved_devices_load_failed">Не удалось загрузить сохранённые устройства.</string> <string name="saved_devices_load_failed">Не удалось загрузить сохранённые устройства.</string>
<string name="saved_devices_loading">Загрузка сохранённых устройств…</string> <string name="saved_devices_loading">Загрузка сохранённых устройств…</string>
<string name="saved_devices_more_actions">Другие действия для %1$s</string> <string name="saved_devices_more_actions">Другие действия для %1$s</string>
<string name="saved_devices_no_pending">Нет запросов на сопряжение, требующих внимания.</string> <string name="saved_devices_no_pending">Нет запросов на сопряжение, требующих внимания.</string>
<string name="saved_devices_unnamed">Saved device</string> <string name="saved_devices_unnamed">Сохранённое устройство</string>
<string name="saved_devices_remember_action">Remember</string> <string name="saved_devices_remember_action">Запомнить</string>
<string name="saved_devices_decline_action">Decline</string> <string name="saved_devices_decline_action">Отклонить</string>
<string name="saved_devices_accept_pairing_action">Accept</string> <string name="saved_devices_accept_pairing_action">Принять</string>
<string name="saved_devices_send_action">Send files</string> <string name="saved_devices_send_action">Отправить файлы</string>
<string name="saved_devices_label_action">Label</string> <string name="saved_devices_label_action">Метка</string>
<string name="saved_devices_forget_action">Forget</string> <string name="saved_devices_forget_action">Забыть</string>
<string name="saved_devices_block_action">Block</string> <string name="saved_devices_block_action">Заблокировать</string>
<string name="saved_devices_label_title">Device label</string> <string name="saved_devices_label_title">Метка устройства</string>
<string name="saved_devices_label_placeholder">Label</string> <string name="saved_devices_label_placeholder">Метка</string>
<string name="saved_devices_label_save">Save</string> <string name="saved_devices_label_save">Сохранить</string>
<string name="saved_devices_label_clear">Clear label</string> <string name="saved_devices_label_clear">Очистить метку</string>
<string name="saved_devices_pending_outgoing">Waiting for the other device</string> <string name="saved_devices_pending_outgoing">Ожидание другого устройства</string>
<string name="saved_devices_pending_incoming">Wants to remember this device</string> <string name="saved_devices_pending_incoming">Хочет запомнить это устройство</string>
<string name="saved_devices_send_started">Transfer offer sent</string> <string name="saved_devices_send_started">Предложение передачи отправлено</string>
<string name="saved_devices_forgotten">Saved device forgotten</string> <string name="saved_devices_forgotten">Сохранённое устройство забыто</string>
<string name="saved_devices_blocked">Device blocked</string> <string name="saved_devices_blocked">Устройство заблокировано</string>
<string name="saved_devices_labeled">Label updated</string> <string name="saved_devices_labeled">Метка обновлена</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d файл</item> <item quantity="one">%1$d файл</item>
<item quantity="few">%1$d файла</item> <item quantity="few">%1$d файла</item>

View File

@@ -265,6 +265,8 @@
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Transfer data</string> <string name="storage_transfer_data">Transfer data</string>
<string name="storage_transfers_deleted">All transfers deleted</string> <string name="storage_transfers_deleted">All transfers deleted</string>
<string name="targeted_offer_body">%1$s wants to send you “%2$s”.</string>
<string name="targeted_offer_title">Incoming transfer</string>
<string name="transfer_activity_description">See important updates for this transfer</string> <string name="transfer_activity_description">See important updates for this transfer</string>
<string name="transfer_activity_title">Activity</string> <string name="transfer_activity_title">Activity</string>
<string name="transfer_delete_description">“%1$s” will stop being shared and its transfer history will be removed from this device.</string> <string name="transfer_delete_description">“%1$s” will stop being shared and its transfer history will be removed from this device.</string>