mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
Compare commits
6 Commits
feat/devic
...
feat/devic
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ac2faf063 | |||
| 442a533eae | |||
| 6b6d5f158d | |||
| 2d4fcd78b5 | |||
| 0ea9a8e49c | |||
| 8bb1442338 |
@@ -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
|
||||
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
|
||||
without its device-bound secrets reconciles to disabled relationships, never a
|
||||
cloned identity.
|
||||
|
||||
@@ -95,6 +95,13 @@ label, forget, and block operations. Wrap those calls through the existing
|
||||
Apple `CoreGateway` / `CoreRepository` boundary rather than invoking generated
|
||||
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
|
||||
presentation code. Share behavior and vocabulary across platforms, not widget
|
||||
implementations. Before handoff, run `make check-localization` and
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import XCTest
|
||||
import VnidropCore
|
||||
@testable import VniDrop
|
||||
|
||||
/// Ports app-level assertions: core initialization on launch, destination
|
||||
@@ -50,4 +51,21 @@ final class AppModelTests: XCTestCase {
|
||||
await waitUntil { 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ private final class BlockingCoreBindingFactory: CoreBindingFactory, @unchecked S
|
||||
throw BlockingCoreFactoryError.stopped
|
||||
}
|
||||
|
||||
func resetUnrecoverableIdentity(
|
||||
appDataDir: String,
|
||||
eventSink: CoreEventSink,
|
||||
networkConfiguration: RelayConfiguration
|
||||
) throws -> VnidropCore {
|
||||
throw BlockingCoreFactoryError.stopped
|
||||
}
|
||||
|
||||
func waitUntilInitializationStarts() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
lock.lock()
|
||||
|
||||
@@ -27,6 +27,7 @@ final class FakeCoreGateway: CoreGateway {
|
||||
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
|
||||
var initializeResult: Result<Void, Error> = .success(())
|
||||
var initializeResults: [Result<Void, Error>] = []
|
||||
var resetUnrecoverableIdentityResult: Result<Void, Error> = .success(())
|
||||
|
||||
// Recorded calls
|
||||
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 lastShareAccessPolicy: ShareAccessPolicy?
|
||||
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
|
||||
private(set) var resetUnrecoverableIdentityCount = 0
|
||||
|
||||
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 initialize(
|
||||
@@ -54,6 +74,19 @@ final class FakeCoreGateway: CoreGateway {
|
||||
stateSubject.send(s)
|
||||
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 shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
||||
lastShareAccessPolicy = accessPolicy
|
||||
@@ -81,6 +114,141 @@ final class FakeCoreGateway: CoreGateway {
|
||||
return responseResult
|
||||
}
|
||||
func refresh() async -> Result<Void, Error> { .success(()) }
|
||||
|
||||
// MARK: - Saved devices
|
||||
|
||||
// Stubbed results
|
||||
var savedDevices: [SavedDeviceModel] = []
|
||||
/// Overrides `savedDevices` when set, so a test can fail one leg of the
|
||||
/// five-read snapshot without stubbing the rest.
|
||||
var savedDevicesResult: Result<[SavedDeviceModel], Error>?
|
||||
var deviceRelationships: [DeviceRelationshipModel] = []
|
||||
var pairingEligibilities: [PairingEligibilityModel] = []
|
||||
var blockedDevices: [String] = []
|
||||
var pendingTargetedOffers: [PendingTargetedOfferModel] = []
|
||||
var targetedTransfers: [TargetedTransferModel] = []
|
||||
var setLabelResult: Result<Void, Error> = .success(())
|
||||
var forgetResult: Result<Void, Error> = .success(())
|
||||
var blockResult: Result<Void, Error> = .success(())
|
||||
var requestPairingResult: Result<Bool, Error> = .success(true)
|
||||
var respondToPairingResult: Result<Bool, Error> = .success(true)
|
||||
var offerResponseResult: Result<TargetedOfferResponseModel, Error> = .success(.declined)
|
||||
var createTargetedTransferResult: Result<TargetedTransferModel, Error> = .failure(TestError.unimplemented)
|
||||
var targetedReceiveResult: Result<Void, Error> = .success(())
|
||||
var targetedCancelResult: Result<Void, Error> = .success(())
|
||||
var targetedDeleteResult: Result<Void, Error> = .success(())
|
||||
|
||||
// Recorded calls
|
||||
private(set) var setLabels: [(peerEndpointId: String, label: String?)] = []
|
||||
private(set) var forgottenDevices: [String] = []
|
||||
private(set) var blockedCalls: [String] = []
|
||||
private(set) var unblockedCalls: [String] = []
|
||||
private(set) var declinedEligibilities: [String] = []
|
||||
private(set) var requestedPairings: [String] = []
|
||||
private(set) var pairingResponses: [(peerEndpointId: String, accepted: Bool)] = []
|
||||
private(set) var offerResponses: [(transferId: String, accepted: Bool)] = []
|
||||
private(set) var createdTargetedTransfers: [(receiverEndpointId: String, sources: [ShareSource], transferName: String?)] = []
|
||||
private(set) var targetedReceives: [(transferId: String, outputDirectoryUrl: String)] = []
|
||||
private(set) var targetedResumes: [(id: String, outputDirectoryUrl: String)] = []
|
||||
private(set) var cancelledTargetedTransfers: [String] = []
|
||||
private(set) var deletedTargetedTransfers: [String] = []
|
||||
|
||||
func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error> {
|
||||
.success(pairingEligibilities)
|
||||
}
|
||||
func declinePairingEligibility(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
declinedEligibilities.append(peerEndpointId)
|
||||
return .success(())
|
||||
}
|
||||
func requestSavedDevicePairing(peerEndpointId: String) async -> Result<Bool, Error> {
|
||||
requestedPairings.append(peerEndpointId)
|
||||
return requestPairingResult
|
||||
}
|
||||
func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||
pairingResponses.append((peerEndpointId, accepted))
|
||||
return respondToPairingResult
|
||||
}
|
||||
func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error> {
|
||||
.success(deviceRelationships)
|
||||
}
|
||||
func listSavedDevices() async -> Result<[SavedDeviceModel], Error> {
|
||||
savedDevicesResult ?? .success(savedDevices)
|
||||
}
|
||||
func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result<Void, Error> {
|
||||
setLabels.append((peerEndpointId, label))
|
||||
return setLabelResult
|
||||
}
|
||||
func forgetSavedDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
forgottenDevices.append(peerEndpointId)
|
||||
return forgetResult
|
||||
}
|
||||
func blockDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
blockedCalls.append(peerEndpointId)
|
||||
return blockResult
|
||||
}
|
||||
func unblockDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
unblockedCalls.append(peerEndpointId)
|
||||
return .success(())
|
||||
}
|
||||
func listBlockedDevices() async -> Result<[String], Error> { .success(blockedDevices) }
|
||||
|
||||
// MARK: - Targeted transfers
|
||||
|
||||
func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error> {
|
||||
.success(pendingTargetedOffers)
|
||||
}
|
||||
func respondToTargetedOffer(
|
||||
transferId: String, accepted: Bool
|
||||
) async -> Result<TargetedOfferResponseModel, Error> {
|
||||
offerResponses.append((transferId, accepted))
|
||||
return offerResponseResult
|
||||
}
|
||||
/// 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.
|
||||
@@ -103,6 +271,31 @@ final class FakeFileSystemService: FileSystemService {
|
||||
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
}
|
||||
|
||||
/// Records targeted sends and forwards to the gateway so its recorders and
|
||||
/// stubbed result drive the assertions.
|
||||
private(set) var targetedSends: [(files: [PickedShareFile], transferName: String, receiver: String)] = []
|
||||
|
||||
func sendPickedFilesToSavedDevice(
|
||||
repository: CoreGateway,
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
receiverEndpointId: String
|
||||
) async -> Result<TargetedTransferModel, Error> {
|
||||
targetedSends.append((files, transferName, receiverEndpointId))
|
||||
return await repository.createTargetedTransfer(
|
||||
receiverEndpointId: receiverEndpointId,
|
||||
sources: [],
|
||||
transferName: transferName.isEmpty ? nil : transferName
|
||||
)
|
||||
}
|
||||
|
||||
/// Picker copies released via `discardPickedFiles`.
|
||||
private(set) var discardedFiles: [String] = []
|
||||
|
||||
func discardPickedFiles(_ files: [PickedShareFile]) async {
|
||||
discardedFiles.append(contentsOf: files.map(\.value))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -39,19 +39,20 @@ final class SavedDeviceCoreContractTests: XCTestCase {
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let sink = RecordingSink()
|
||||
let first = try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
||||
var first: VnidropCore? = try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
||||
appDataDir: directory.path,
|
||||
eventSink: sink,
|
||||
limits: defaultCoreLimits(),
|
||||
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
|
||||
)
|
||||
let endpointId = first.status().endpointId
|
||||
let endpointId = first!.status().endpointId
|
||||
XCTAssertFalse(endpointId.isEmpty)
|
||||
XCTAssertFalse(
|
||||
FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path),
|
||||
"protected identity must not fall back to plaintext"
|
||||
)
|
||||
first.shutdown()
|
||||
first?.shutdown()
|
||||
first = nil
|
||||
|
||||
let restarted = try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
||||
appDataDir: directory.path,
|
||||
@@ -134,6 +135,9 @@ final class SavedDeviceCoreContractTests: XCTestCase {
|
||||
let _: (
|
||||
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
|
||||
) = VnidropCore.initializeWithLimitsAndNetworkConfig
|
||||
let _: (
|
||||
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
|
||||
) = VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig
|
||||
let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities()
|
||||
XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1)
|
||||
XCTAssertNotNil(defaultCoreLimits().maxSavedDevices)
|
||||
@@ -165,6 +169,7 @@ final class SavedDeviceCoreContractTests: XCTestCase {
|
||||
XCTAssertFalse(source.contains("ExperimentalSavedDeviceCapabilities"))
|
||||
XCTAssertFalse(source.contains("experimentalSavedDeviceCapabilities"))
|
||||
XCTAssertTrue(source.contains("initializeWithLimitsAndNetworkConfig"))
|
||||
XCTAssertTrue(source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"))
|
||||
XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities"))
|
||||
XCTAssertTrue(source.contains("public func savedDeviceCapabilities()"))
|
||||
XCTAssertTrue(source.contains("setSavedDeviceLabel"))
|
||||
|
||||
167
apple/Tests/SavedDeviceNotificationTests.swift
Normal file
167
apple/Tests/SavedDeviceNotificationTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
669
apple/Tests/SavedDevicesModelTests.swift
Normal file
669
apple/Tests/SavedDevicesModelTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ final class UserFacingErrorTests: XCTestCase {
|
||||
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
|
||||
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").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.SecureStorageLocked(reason: "credential store is locked").canRetryWithoutChangingInput)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ struct RootView: View {
|
||||
@StateObject private var sendModel: SendModel
|
||||
@StateObject private var receiveModel: ReceiveModel
|
||||
@StateObject private var settingsModel: SettingsModel
|
||||
@StateObject private var savedDevicesModel: SavedDevicesModel
|
||||
/// Held so it stays alive for the app's lifetime; it has no view of its own.
|
||||
@StateObject private var savedDeviceNotifications: SavedDeviceNotificationCoordinator
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -44,6 +47,21 @@ struct RootView: View {
|
||||
messages: graph.messages,
|
||||
bugReports: NoopBugReportService()
|
||||
))
|
||||
let savedDevices = SavedDevicesModel(
|
||||
repository: graph.coreRepository,
|
||||
fileSystemService: dependencies.fileSystemService,
|
||||
preferences: graph.preferencesRepository,
|
||||
messages: graph.messages
|
||||
)
|
||||
_savedDevicesModel = StateObject(wrappedValue: savedDevices)
|
||||
// Reads the model's snapshot rather than the core directly, so it observes
|
||||
// exactly the state the UI is showing.
|
||||
_savedDeviceNotifications = StateObject(wrappedValue: SavedDeviceNotificationCoordinator(
|
||||
model: savedDevices,
|
||||
notifications: dependencies.notificationService,
|
||||
visibility: graph.visibility,
|
||||
messages: graph.messages
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -52,6 +70,13 @@ struct RootView: View {
|
||||
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
||||
ZStack {
|
||||
navigation(windowClass: windowClass)
|
||||
// Hosted at the root so a pairing request or targeted offer is
|
||||
// answerable from any tab, and suppressed while a transfer approval
|
||||
// is up so two blocking decisions never stack.
|
||||
.savedDevicePrompts(
|
||||
model: savedDevicesModel,
|
||||
suppressed: graph.approvalCoordinator.state.current != nil
|
||||
)
|
||||
// Observe the coordinator/messages from the *persisted* `graph`
|
||||
// StateObject. Deriving them in `init` bound the view to a throwaway
|
||||
// AppGraph rebuilt on every re-init, whose coordinator never receives
|
||||
@@ -68,7 +93,16 @@ struct RootView: View {
|
||||
// A small, unobtrusive indicator while the core finishes its async
|
||||
// startup — otherwise the lists look empty and the app feels stalled.
|
||||
if !sendModel.coreState.isInitialized {
|
||||
CoreStartingOverlay()
|
||||
CoreStartingOverlay(
|
||||
error: appModel.startupError,
|
||||
detail: appModel.startupErrorDetail,
|
||||
onRetry: appModel.retryStartup,
|
||||
recovery: appModel.startupRecovery,
|
||||
isResettingIdentity: appModel.isResettingIdentity,
|
||||
onResetIdentity: {
|
||||
Task { await appModel.resetUnrecoverableIdentity() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
|
||||
@@ -167,6 +201,8 @@ struct RootView: View {
|
||||
switch destination {
|
||||
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
||||
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
||||
case .savedDevices:
|
||||
SavedDevicesScreen(model: savedDevicesModel, windowClass: windowClass)
|
||||
case .settings:
|
||||
SettingsScreen(model: settingsModel, windowClass: windowClass)
|
||||
}
|
||||
@@ -253,21 +289,115 @@ private struct ApprovalLayer: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A full-window cover with a centered spinner shown while the core is starting.
|
||||
/// A full-window cover shown while the core is starting — or, when startup fails,
|
||||
/// the reason and a retry. This overlay sits above the snackbar host, so a failure
|
||||
/// reported only through a toast would be invisible behind it and the app would
|
||||
/// look like it was loading forever.
|
||||
private struct CoreStartingOverlay: View {
|
||||
let error: UiText?
|
||||
/// Debug builds only; nil in Release.
|
||||
let detail: String?
|
||||
let onRetry: () -> Void
|
||||
let recovery: AppStartupRecovery?
|
||||
let isResettingIdentity: Bool
|
||||
let onResetIdentity: () -> Void
|
||||
@State private var confirmsIdentityReset = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
backgroundColor.ignoresSafeArea()
|
||||
VStack(spacing: 16) {
|
||||
ProgressView().controlSize(.large)
|
||||
Text(String(localized: L10n.App.starting))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
// 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) {
|
||||
ProgressView().controlSize(.large)
|
||||
Text(String(localized: L10n.App.starting))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.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 {
|
||||
@@ -284,10 +414,3 @@ import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Hosts the device-history consent prompts, alongside `ApprovalLayer`.
|
||||
///
|
||||
/// Separate from the approval layer because the two never compete: an approval
|
||||
/// belongs to a transfer this device is sending, and these belong to a device
|
||||
/// asking to reach it. Both are suppressed while the other is up so the user is
|
||||
/// never answering two modals at once.
|
||||
|
||||
@@ -25,6 +25,7 @@ protocol CoreGateway: AnyObject {
|
||||
var signals: AnyPublisher<CoreSignal, Never> { get }
|
||||
|
||||
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
|
||||
func resetUnrecoverableIdentity(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
|
||||
func shutdown()
|
||||
func shareSources(
|
||||
_ sources: [ShareSource],
|
||||
@@ -47,4 +48,51 @@ protocol CoreGateway: AnyObject {
|
||||
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
|
||||
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
|
||||
func refresh() async -> Result<Void, Error>
|
||||
|
||||
// MARK: - Saved devices
|
||||
|
||||
func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error>
|
||||
func declinePairingEligibility(peerEndpointId: String) async -> Result<Void, Error>
|
||||
/// Asks a peer to pair. Returns false when no valid eligibility exists, which
|
||||
/// the core rejects silently so a stranger cannot provoke a prompt.
|
||||
func requestSavedDevicePairing(peerEndpointId: String) async -> Result<Bool, Error>
|
||||
func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result<Bool, Error>
|
||||
func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error>
|
||||
func listSavedDevices() async -> Result<[SavedDeviceModel], Error>
|
||||
/// Sets or clears (`nil`) the user-owned local label.
|
||||
func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result<Void, Error>
|
||||
func forgetSavedDevice(peerEndpointId: String) async -> Result<Void, Error>
|
||||
func blockDevice(peerEndpointId: String) async -> Result<Void, Error>
|
||||
/// Removes only the deny rule. Grants, relationships, and cancelled transfers
|
||||
/// are not restored — re-saving needs another qualifying transfer and consent.
|
||||
func unblockDevice(peerEndpointId: String) async -> Result<Void, Error>
|
||||
func listBlockedDevices() async -> Result<[String], Error>
|
||||
|
||||
// MARK: - Targeted transfers
|
||||
|
||||
func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error>
|
||||
func respondToTargetedOffer(
|
||||
transferId: String,
|
||||
accepted: Bool
|
||||
) async -> Result<TargetedOfferResponseModel, Error>
|
||||
func createTargetedTransfer(
|
||||
receiverEndpointId: String,
|
||||
sources: [ShareSource],
|
||||
transferName: String?
|
||||
) async -> Result<TargetedTransferModel, Error>
|
||||
func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error>
|
||||
/// Pulls an approved transfer into a security-scoped destination, holding
|
||||
/// access for the duration of the stream (mirrors the invitation receive path).
|
||||
func receiveTargetedTransfer(
|
||||
transferId: String,
|
||||
outputDirectoryUrl: String
|
||||
) async -> Result<Void, Error>
|
||||
/// Resumes an interrupted transfer. The same immutable transfer continues from
|
||||
/// its verified progress and is not re-approved.
|
||||
func resumeTargetedTransfer(
|
||||
id: String,
|
||||
outputDirectoryUrl: String
|
||||
) async -> Result<Void, Error>
|
||||
func cancelTargetedTransfer(id: String) async -> Result<Void, Error>
|
||||
func deleteTargetedTransfer(id: String) async -> Result<Void, Error>
|
||||
}
|
||||
|
||||
@@ -27,6 +27,30 @@ struct CoreEventModel: Equatable, Identifiable, Sendable {
|
||||
var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) }
|
||||
var eventPhase: EventPhase? { EventPhase(rawValue: phase) }
|
||||
var eventKind: EventKind? { EventKind(rawValue: kind) }
|
||||
|
||||
/// Subject of a `.targetedTransfer` event.
|
||||
///
|
||||
/// This is an identifier, not state: it says *which* transfer changed, which
|
||||
/// is all a consumer may take from an event before re-reading durable state.
|
||||
/// It exists because the id is otherwise unobtainable while `create` is still
|
||||
/// running — that call occupies the serial lane, so no query can answer until
|
||||
/// it returns, which is exactly when the user wants to cancel it.
|
||||
var targetedTransferId: String? {
|
||||
guard eventPhase == .targetedTransfer, let data = dataJson.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONDecoder().decode(TargetedTransferEventData.self, from: data).targetedTransferId
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload of a `.targetedTransfer` event. Named keys rather than a raw string
|
||||
/// subscript so the wire contract lives in one declared place.
|
||||
private struct TargetedTransferEventData: Decodable {
|
||||
let targetedTransferId: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case targetedTransferId = "targeted_transfer_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction of a core event, matching the wire strings the core emits.
|
||||
@@ -47,6 +71,12 @@ enum EventPhase: String, Equatable, Sendable {
|
||||
case network
|
||||
case handshake
|
||||
case error
|
||||
/// Saved-device consent lifecycle (eligibility, relationships, grants).
|
||||
case pairing
|
||||
/// Targeted-transfer offer and lifecycle. Its events identify the transfer by
|
||||
/// a string `targeted_transfer_id`, not the numeric `transferId` used by
|
||||
/// invitation shares.
|
||||
case targetedTransfer = "targeted_transfer"
|
||||
}
|
||||
|
||||
/// Kind of a core progress event (the `kind` wire field).
|
||||
@@ -174,6 +204,13 @@ enum CoreSignal: Equatable, Sendable {
|
||||
case receiverHistoryChanged(transferId: UInt64)
|
||||
/// Transfer status/history changed enough to re-read the durable snapshot.
|
||||
case transfersChanged(transferId: UInt64)
|
||||
/// Pairing / saved-device state changed; refresh eligibility, relationships,
|
||||
/// and the saved list. Carries no payload: core events are wake-ups, not
|
||||
/// authoritative state, so consumers re-query rather than apply a delta.
|
||||
case pairingChanged
|
||||
/// Targeted-transfer offer or lifecycle changed; refresh pending offers and
|
||||
/// transfers. Payload-free for the same reason as `pairingChanged`.
|
||||
case targetedTransferChanged
|
||||
}
|
||||
|
||||
// MARK: - Transfer helpers (ported from AppUiModels.kt)
|
||||
|
||||
@@ -28,36 +28,50 @@ protocol CoreBindingFactory: Sendable {
|
||||
eventSink: CoreEventSink,
|
||||
networkConfiguration: RelayConfiguration
|
||||
) throws -> VnidropCore
|
||||
func resetUnrecoverableIdentity(
|
||||
appDataDir: String,
|
||||
eventSink: CoreEventSink,
|
||||
networkConfiguration: RelayConfiguration
|
||||
) throws -> VnidropCore
|
||||
}
|
||||
|
||||
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(
|
||||
appDataDir: String,
|
||||
eventSink: CoreEventSink,
|
||||
networkConfiguration: RelayConfiguration
|
||||
) 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(
|
||||
appDataDir: appDataDir,
|
||||
eventSink: eventSink,
|
||||
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() {
|
||||
core?.shutdown()
|
||||
core = nil
|
||||
@@ -293,6 +336,140 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
if let snapshot { self.applySnapshot(snapshot) }
|
||||
}
|
||||
}
|
||||
// MARK: - Saved devices
|
||||
|
||||
func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error> {
|
||||
await runCore { try self.requireCore().listPairingEligibilities().map { $0.toModel() } }
|
||||
}
|
||||
|
||||
func declinePairingEligibility(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
await runCore { try self.requireCore().declinePairingEligibility(peerEndpointId: peerEndpointId) }
|
||||
}
|
||||
|
||||
func requestSavedDevicePairing(peerEndpointId: String) async -> Result<Bool, Error> {
|
||||
await runCore { try self.requireCore().requestSavedDevicePairing(peerEndpointId: peerEndpointId) }
|
||||
}
|
||||
|
||||
func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||
await runCore {
|
||||
try self.requireCore().respondToDevicePairing(peerEndpointId: peerEndpointId, accepted: accepted)
|
||||
}
|
||||
}
|
||||
|
||||
func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error> {
|
||||
await runCore { try self.requireCore().listDeviceRelationships().map { $0.toModel() } }
|
||||
}
|
||||
|
||||
func listSavedDevices() async -> Result<[SavedDeviceModel], Error> {
|
||||
await runCore { try self.requireCore().listSavedDevices().map { $0.toModel() } }
|
||||
}
|
||||
|
||||
func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result<Void, Error> {
|
||||
await runCore { try self.requireCore().setSavedDeviceLabel(peerEndpointId: peerEndpointId, label: label) }
|
||||
}
|
||||
|
||||
func forgetSavedDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
// Forget cancels the peer's active/resumable targeted transfers, so it can
|
||||
// run while one is streaming on the serial lane — same reasoning as `cancel`.
|
||||
await runInterrupt { try self.requireCore().forgetSavedDevice(peerEndpointId: peerEndpointId) }
|
||||
}
|
||||
|
||||
func blockDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
// Block is immediate and identity-wide, cancelling traffic in flight.
|
||||
await runInterrupt { try self.requireCore().blockDevice(peerEndpointId: peerEndpointId) }
|
||||
}
|
||||
|
||||
func unblockDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
await runCore { try self.requireCore().unblockDevice(peerEndpointId: peerEndpointId) }
|
||||
}
|
||||
|
||||
func listBlockedDevices() async -> Result<[String], Error> {
|
||||
await runCore { try self.requireCore().listBlockedDevices() }
|
||||
}
|
||||
|
||||
// MARK: - Targeted transfers
|
||||
|
||||
func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error> {
|
||||
// `listPendingTargetedOffers` is itself non-throwing, but `requireCore` is.
|
||||
await runCore { try self.requireCore().listPendingTargetedOffers().map { $0.toModel() } }
|
||||
}
|
||||
|
||||
func respondToTargetedOffer(
|
||||
transferId: String,
|
||||
accepted: Bool
|
||||
) async -> Result<TargetedOfferResponseModel, Error> {
|
||||
await runCore {
|
||||
try self.requireCore()
|
||||
.respondToTargetedOffer(transferId: transferId, accepted: accepted)
|
||||
.toModel()
|
||||
}
|
||||
}
|
||||
|
||||
func createTargetedTransfer(
|
||||
receiverEndpointId: String,
|
||||
sources: [ShareSource],
|
||||
transferName: String?
|
||||
) async -> Result<TargetedTransferModel, Error> {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
guard !sources.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
return await runCore {
|
||||
try self.requireCore().createTargetedTransfer(
|
||||
receiverEndpointId: receiverEndpointId,
|
||||
sources: sources,
|
||||
transferName: transferName
|
||||
).toModel()
|
||||
}
|
||||
}
|
||||
|
||||
func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> {
|
||||
await runCore { try self.requireCore().listTargetedTransfers().map { $0.toModel() } }
|
||||
}
|
||||
|
||||
func receiveTargetedTransfer(
|
||||
transferId: String,
|
||||
outputDirectoryUrl: String
|
||||
) async -> Result<Void, Error> {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
return await runCore {
|
||||
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
|
||||
try self.requireCore().receiveTargetedTransfer(
|
||||
transferId: transferId,
|
||||
outputDir: outputDirectoryUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resumeTargetedTransfer(
|
||||
id: String,
|
||||
outputDirectoryUrl: String
|
||||
) async -> Result<Void, Error> {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
return await runCore {
|
||||
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
|
||||
try self.requireCore().resumeTargetedTransfer(id: id, outputDir: outputDirectoryUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelTargetedTransfer(id: String) async -> Result<Void, Error> {
|
||||
// Off the serial lane: an in-flight targeted receive is blocking it, and the
|
||||
// cancel must reach the core to unblock that receive (see `cancel`).
|
||||
await runInterrupt { try self.requireCore().cancelTargetedTransfer(id: id) }
|
||||
}
|
||||
|
||||
func deleteTargetedTransfer(id: String) async -> Result<Void, Error> {
|
||||
await runCore { try self.requireCore().deleteTargetedTransfer(id: id) }
|
||||
}
|
||||
|
||||
// MARK: - Event sink handling (ported from CoreRepository.sink)
|
||||
|
||||
private func handle(event: CoreEvent) {
|
||||
@@ -302,6 +479,21 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
|
||||
state.events = events
|
||||
|
||||
// Saved-device events identify their subject by peer endpoint or a string
|
||||
// `targeted_transfer_id`, so they carry no numeric `transferId` and must be
|
||||
// dispatched before the guard below. Both are payload-free wake-ups: the
|
||||
// consumer re-reads durable state rather than trusting the event.
|
||||
switch model.eventPhase {
|
||||
case .pairing:
|
||||
signalsSubject.send(.pairingChanged)
|
||||
return
|
||||
case .targetedTransfer:
|
||||
signalsSubject.send(.targetedTransferChanged)
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
guard let transferId = model.transferId else { return }
|
||||
switch model.phase {
|
||||
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
|
||||
@@ -497,6 +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 {
|
||||
func toModel() -> ReceiverRequestModel {
|
||||
ReceiverRequestModel(
|
||||
@@ -522,4 +806,3 @@ private extension ReceiverRequest {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,15 @@ protocol FileSystemService {
|
||||
senderName: String,
|
||||
destination: ShareDestination
|
||||
) async -> Result<Share, Error>
|
||||
/// Sends a picked selection straight to one saved device. Separate from
|
||||
/// `sharePickedFiles` because a targeted transfer is its own domain with its
|
||||
/// own result type — it is not an access mode on an invitation share.
|
||||
func sendPickedFilesToSavedDevice(
|
||||
repository: CoreGateway,
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
receiverEndpointId: String
|
||||
) async -> Result<TargetedTransferModel, Error>
|
||||
}
|
||||
|
||||
extension FileSystemService {
|
||||
|
||||
182
apple/VniDrop/Core/SavedDeviceModels.swift
Normal file
182
apple/VniDrop/Core/SavedDeviceModels.swift
Normal 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) }
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import VnidropCore
|
||||
|
||||
enum AppStartupRecovery: Equatable {
|
||||
case identityUnrecoverable
|
||||
}
|
||||
|
||||
/// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the
|
||||
/// core on launch and tracks the selected destination + theme.
|
||||
@@ -7,10 +12,24 @@ import Combine
|
||||
final class AppModel: ObservableObject {
|
||||
@Published private(set) var destination: AppDestination = .send
|
||||
@Published private(set) var themeMode: ThemeMode = .system
|
||||
/// Why startup failed, or nil while it is still in progress or has succeeded.
|
||||
/// The startup overlay covers the snackbar, so without this a failed
|
||||
/// `initialize` was indistinguishable from an app that never finished loading.
|
||||
/// 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 repository: CoreGateway
|
||||
private let messages: UiMessageController
|
||||
private let appDataDir: String
|
||||
private let networkConfiguration: RelayConfiguration
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
@@ -22,16 +41,12 @@ final class AppModel: ObservableObject {
|
||||
self.environment = environment
|
||||
self.repository = repository
|
||||
self.messages = messages
|
||||
self.appDataDir = environment.defaultCoreDataDir
|
||||
self.networkConfiguration = preferences.preferences.relayConfiguration
|
||||
|
||||
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
|
||||
|
||||
Task {
|
||||
let result = await repository.initialize(
|
||||
appDataDir: environment.defaultCoreDataDir,
|
||||
networkConfiguration: preferences.preferences.relayConfiguration
|
||||
)
|
||||
if case .failure(let error) = result { messages.error(error) }
|
||||
}
|
||||
Task { await initializeCore() }
|
||||
|
||||
preferences.$preferences
|
||||
.map(\.themeMode)
|
||||
@@ -40,8 +55,68 @@ final class AppModel: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Runs core startup, keeping the failure reason for the overlay to show.
|
||||
/// Also logged, because a user-facing message alone is not diagnosable.
|
||||
func initializeCore() async {
|
||||
startupError = nil
|
||||
startupErrorDetail = nil
|
||||
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) {
|
||||
guard destination != self.destination else { return }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,10 @@ final class TransferNotificationCoordinator: ObservableObject {
|
||||
Task { await self.syncReceivers(transferId: transferId) }
|
||||
case .approvalChanged:
|
||||
break
|
||||
case .pairingChanged, .targetedTransferChanged:
|
||||
// Saved-device notifications are owned by the saved-device
|
||||
// coordinator, which tracks its own notification identifiers.
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
577
apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift
Normal file
577
apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
127
apple/VniDrop/Features/SavedDevices/SavedDevicePrompts.swift
Normal file
127
apple/VniDrop/Features/SavedDevices/SavedDevicePrompts.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
715
apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift
Normal file
715
apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
248
apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift
Normal file
248
apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
230
apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift
Normal file
230
apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,11 @@ final class SendModel: ObservableObject {
|
||||
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
||||
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
||||
self.refreshReceiverStatuses(for: id)
|
||||
case .pairingChanged, .targetedTransferChanged:
|
||||
// Saved-device domain; owned by SavedDevicesModel. Listed
|
||||
// explicitly rather than via `default` so a new signal still
|
||||
// forces a decision here.
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -73,6 +73,24 @@ struct IosFileSystemService: FileSystemService {
|
||||
)
|
||||
}
|
||||
|
||||
func sendPickedFilesToSavedDevice(
|
||||
repository: CoreGateway,
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
receiverEndpointId: String
|
||||
) async -> Result<TargetedTransferModel, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
// iOS picks by copying into the container, so no security scope has to be
|
||||
// re-acquired here (unlike macOS).
|
||||
return await repository.createTargetedTransfer(
|
||||
receiverEndpointId: receiverEndpointId,
|
||||
sources: files.map { $0.toIosShareSource() },
|
||||
transferName: transferName.isEmpty ? nil : transferName
|
||||
)
|
||||
}
|
||||
|
||||
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
|
||||
let url = URL(string: value) ?? URL(fileURLWithPath: value)
|
||||
let started = url.startAccessingSecurityScopedResource()
|
||||
|
||||
@@ -44,10 +44,42 @@ struct MacFileSystemService: FileSystemService {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
// Re-acquire security-scoped access to every picked source (from the bookmark
|
||||
// captured at pick time) and hold it across the whole share call. The core
|
||||
// imports the bytes during shareFiles(), so access only needs to survive that
|
||||
// call; without this, the import fails with EPERM under the App Store sandbox.
|
||||
guard case .invitation(let accessPolicy) = destination else {
|
||||
return .failure(InvitationError.unsupportedOperation)
|
||||
}
|
||||
return await withScopedSources(files) { sources in
|
||||
await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func sendPickedFilesToSavedDevice(
|
||||
repository: CoreGateway,
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
receiverEndpointId: String
|
||||
) async -> Result<TargetedTransferModel, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
return await withScopedSources(files) { sources in
|
||||
await repository.createTargetedTransfer(
|
||||
receiverEndpointId: receiverEndpointId,
|
||||
sources: sources,
|
||||
transferName: transferName.isEmpty ? nil : transferName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-acquires security-scoped access to every picked source (from the bookmark
|
||||
/// captured at pick time) and holds it across `body`. The core imports the bytes
|
||||
/// during that call, so access only needs to survive it; without this the import
|
||||
/// fails with EPERM under the App Store sandbox.
|
||||
private func withScopedSources<T>(
|
||||
_ files: [PickedShareFile],
|
||||
_ body: ([ShareSource]) async -> Result<T, Error>
|
||||
) async -> Result<T, Error> {
|
||||
var scopedURLs: [URL] = []
|
||||
for file in files {
|
||||
guard let bookmark = file.securityScopeBookmark else { continue }
|
||||
@@ -63,12 +95,7 @@ struct MacFileSystemService: FileSystemService {
|
||||
let sources = files.map {
|
||||
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
||||
}
|
||||
guard case .invitation(let accessPolicy) = destination else {
|
||||
return .failure(InvitationError.unsupportedOperation)
|
||||
}
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
return await body(sources)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -75,10 +75,6 @@
|
||||
<true/>
|
||||
<key>NFCReaderUsageDescription</key>
|
||||
<string>VniDrop uses NFC to read transfer invitation tags.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string></string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>VniDrop uses the camera to scan transfer QR codes.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
@@ -87,10 +83,6 @@
|
||||
view. Mirrors the single-window macOS behavior. -->
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
|
||||
@@ -107,7 +107,9 @@ extension Error {
|
||||
var canRetryWithoutChangingInput: Bool {
|
||||
guard let vni = self as? VnidropError else { return true }
|
||||
switch vni {
|
||||
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false
|
||||
case .FilesystemPermission, .DestinationExists, .InvalidInput,
|
||||
.SecureStorageMissing, .SecureStorageCorrupted:
|
||||
return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SFSafeSymbols
|
||||
enum AppDestination: String, CaseIterable, Identifiable {
|
||||
case send
|
||||
case receive
|
||||
case savedDevices
|
||||
case settings
|
||||
|
||||
var id: String { rawValue }
|
||||
@@ -13,6 +14,7 @@ enum AppDestination: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .send: return L10n.Nav.send
|
||||
case .receive: return L10n.Nav.receive
|
||||
case .savedDevices: return L10n.Nav.savedDevices
|
||||
case .settings: return L10n.Nav.settings
|
||||
}
|
||||
}
|
||||
@@ -22,6 +24,7 @@ enum AppDestination: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .send: return .paperplane
|
||||
case .receive: return .trayAndArrowDown
|
||||
case .savedDevices: return .macbookAndIphone
|
||||
case .settings: return .gearshape
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,13 @@ bytes through Kotlin memory.
|
||||
work before durable cleanup; forget and block revoke affected relationships
|
||||
and active targeted work within their core operation. All four durably deny
|
||||
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
|
||||
their existing experimental preference gates is outside this release gate.
|
||||
|
||||
|
||||
127
crates/vnidrop/src/identity_recovery.rs
Normal file
127
crates/vnidrop/src/identity_recovery.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod event_hub;
|
||||
mod filesystem;
|
||||
mod grant;
|
||||
mod handshake;
|
||||
mod identity_recovery;
|
||||
mod invitation;
|
||||
mod logging;
|
||||
mod pairing_eligibility;
|
||||
|
||||
@@ -11,6 +11,7 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use crate::{
|
||||
blocked_devices::{self, BlockStore},
|
||||
device_relationship::DeviceRelationshipStore,
|
||||
identity_recovery::IdentityRecoveryStore,
|
||||
invitation::Repository,
|
||||
pairing_eligibility::PairingEligibilityStore,
|
||||
secure_secret::{self, SecretMetadataStore},
|
||||
@@ -32,6 +33,8 @@ pub(crate) struct AppDataStores {
|
||||
pub(crate) secrets: SecretMetadataStore,
|
||||
/// Identity-wide deny list.
|
||||
pub(crate) blocked: BlockStore,
|
||||
/// Explicit endpoint-identity reset transaction.
|
||||
pub(crate) identity_recovery: IdentityRecoveryStore,
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
eligibility: PairingEligibilityStore::new(pool.clone()),
|
||||
secrets: SecretMetadataStore::new(pool.clone()),
|
||||
identity_recovery: IdentityRecoveryStore::new(pool.clone()),
|
||||
blocked: BlockStore::new(pool),
|
||||
invitation,
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ impl VnidropCore {
|
||||
self.runtime.handle().block_on(future)
|
||||
}
|
||||
|
||||
fn initialize_with_identity_mode(
|
||||
pub(super) fn initialize_with_identity_mode(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
limits: CoreLimits,
|
||||
@@ -497,6 +497,24 @@ impl VnidropCore {
|
||||
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 {
|
||||
self.block_on(self.inner.status())
|
||||
}
|
||||
|
||||
89
crates/vnidrop/src/runtime/identity_recovery.rs
Normal file
89
crates/vnidrop/src/runtime/identity_recovery.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
mod delivery;
|
||||
mod facade;
|
||||
mod identity_recovery;
|
||||
mod lifecycle;
|
||||
mod provider;
|
||||
mod receive;
|
||||
|
||||
@@ -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(
|
||||
&self,
|
||||
kind: SecretKind,
|
||||
@@ -872,6 +930,23 @@ impl FaultInjectingSecretStore {
|
||||
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> {
|
||||
match *self.failure.lock().unwrap() {
|
||||
Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked),
|
||||
|
||||
@@ -62,6 +62,7 @@ fn public_api_exposes_saved_device_surface_without_prototype_contact_entry_point
|
||||
"fn share_files(",
|
||||
"fn receive(",
|
||||
"saved_device_capabilities",
|
||||
"fn reset_unrecoverable_identity_with_limits_and_network_config(",
|
||||
] {
|
||||
assert!(
|
||||
facade.contains(required) || api.contains(required) || lib.contains(required),
|
||||
|
||||
@@ -682,6 +682,73 @@ fn saved_remote_name_and_local_label_survive_restart() {
|
||||
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]
|
||||
fn events_carry_stable_ids_and_monotonic_revisions() {
|
||||
let alice = ProtectedNode::new();
|
||||
|
||||
@@ -847,6 +847,10 @@ fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() {
|
||||
source.contains("initializeWithLimitsAndNetworkConfig"),
|
||||
"Swift bindings must expose standard protected initialization"
|
||||
);
|
||||
assert!(
|
||||
source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"),
|
||||
"Swift bindings must expose explicit endpoint-identity recovery"
|
||||
);
|
||||
assert!(
|
||||
source.contains("public struct SavedDeviceCapabilities")
|
||||
&& source.contains("public func savedDeviceCapabilities()"),
|
||||
|
||||
@@ -338,6 +338,57 @@ fn create_targeted_transfer_is_immutable_and_saved_only() {
|
||||
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]
|
||||
fn preapproval_offer_is_authenticated_without_ordinary_share_ticket() {
|
||||
let alice = ProtectedNode::new();
|
||||
|
||||
@@ -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)
|
||||
(`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
|
||||
|
||||
Write named tokens `{count}`, `{name}` in text. The generator converts them to the right
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -265,6 +265,8 @@
|
||||
<string name="storage_total">Gesamt</string>
|
||||
<string name="storage_transfer_data">Übertragungsdaten</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_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>
|
||||
@@ -345,31 +347,31 @@
|
||||
<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_title">Gerät vergessen?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
<string name="saved_devices_pending_title">Pending pairing</string>
|
||||
<string name="saved_devices_list_title">Saved devices</string>
|
||||
<string name="saved_devices_eligibility_title">Bereit zum Speichern</string>
|
||||
<string name="saved_devices_pending_title">Ausstehende Kopplung</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_loading">Gespeicherte Geräte werden geladen…</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_unnamed">Saved device</string>
|
||||
<string name="saved_devices_remember_action">Remember</string>
|
||||
<string name="saved_devices_decline_action">Decline</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accept</string>
|
||||
<string name="saved_devices_send_action">Send files</string>
|
||||
<string name="saved_devices_label_action">Label</string>
|
||||
<string name="saved_devices_forget_action">Forget</string>
|
||||
<string name="saved_devices_block_action">Block</string>
|
||||
<string name="saved_devices_label_title">Device label</string>
|
||||
<string name="saved_devices_label_placeholder">Label</string>
|
||||
<string name="saved_devices_label_save">Save</string>
|
||||
<string name="saved_devices_label_clear">Clear label</string>
|
||||
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
|
||||
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
|
||||
<string name="saved_devices_send_started">Transfer offer sent</string>
|
||||
<string name="saved_devices_forgotten">Saved device forgotten</string>
|
||||
<string name="saved_devices_blocked">Device blocked</string>
|
||||
<string name="saved_devices_labeled">Label updated</string>
|
||||
<string name="saved_devices_unnamed">Gespeichertes Gerät</string>
|
||||
<string name="saved_devices_remember_action">Speichern</string>
|
||||
<string name="saved_devices_decline_action">Ablehnen</string>
|
||||
<string name="saved_devices_accept_pairing_action">Annehmen</string>
|
||||
<string name="saved_devices_send_action">Dateien senden</string>
|
||||
<string name="saved_devices_label_action">Bezeichnung</string>
|
||||
<string name="saved_devices_forget_action">Vergessen</string>
|
||||
<string name="saved_devices_block_action">Blockieren</string>
|
||||
<string name="saved_devices_label_title">Gerätebezeichnung</string>
|
||||
<string name="saved_devices_label_placeholder">Bezeichnung</string>
|
||||
<string name="saved_devices_label_save">Speichern</string>
|
||||
<string name="saved_devices_label_clear">Bezeichnung löschen</string>
|
||||
<string name="saved_devices_pending_outgoing">Warten auf das andere Gerät</string>
|
||||
<string name="saved_devices_pending_incoming">Möchte dieses Gerät speichern</string>
|
||||
<string name="saved_devices_send_started">Übertragungsangebot gesendet</string>
|
||||
<string name="saved_devices_forgotten">Gespeichertes Gerät vergessen</string>
|
||||
<string name="saved_devices_blocked">Gerät blockiert</string>
|
||||
<string name="saved_devices_labeled">Bezeichnung aktualisiert</string>
|
||||
<plurals name="transfer_file_count">
|
||||
<item quantity="one">%1$d Datei</item>
|
||||
<item quantity="other">%1$d Dateien</item>
|
||||
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="storage_total">Total</string>
|
||||
<string name="storage_transfer_data">Datos de transferencia</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_title">Actividad</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_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_eligibility_title">Ready to remember</string>
|
||||
<string name="saved_devices_pending_title">Pending pairing</string>
|
||||
<string name="saved_devices_list_title">Saved devices</string>
|
||||
<string name="saved_devices_eligibility_title">Listo para recordar</string>
|
||||
<string name="saved_devices_pending_title">Vinculación pendiente</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_loading">Cargando dispositivos guardados…</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_unnamed">Saved device</string>
|
||||
<string name="saved_devices_remember_action">Remember</string>
|
||||
<string name="saved_devices_decline_action">Decline</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accept</string>
|
||||
<string name="saved_devices_send_action">Send files</string>
|
||||
<string name="saved_devices_label_action">Label</string>
|
||||
<string name="saved_devices_forget_action">Forget</string>
|
||||
<string name="saved_devices_block_action">Block</string>
|
||||
<string name="saved_devices_label_title">Device label</string>
|
||||
<string name="saved_devices_label_placeholder">Label</string>
|
||||
<string name="saved_devices_label_save">Save</string>
|
||||
<string name="saved_devices_label_clear">Clear label</string>
|
||||
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
|
||||
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
|
||||
<string name="saved_devices_send_started">Transfer offer sent</string>
|
||||
<string name="saved_devices_forgotten">Saved device forgotten</string>
|
||||
<string name="saved_devices_blocked">Device blocked</string>
|
||||
<string name="saved_devices_labeled">Label updated</string>
|
||||
<string name="saved_devices_unnamed">Dispositivo guardado</string>
|
||||
<string name="saved_devices_remember_action">Recordar</string>
|
||||
<string name="saved_devices_decline_action">Rechazar</string>
|
||||
<string name="saved_devices_accept_pairing_action">Aceptar</string>
|
||||
<string name="saved_devices_send_action">Enviar archivos</string>
|
||||
<string name="saved_devices_label_action">Etiqueta</string>
|
||||
<string name="saved_devices_forget_action">Olvidar</string>
|
||||
<string name="saved_devices_block_action">Bloquear</string>
|
||||
<string name="saved_devices_label_title">Etiqueta del dispositivo</string>
|
||||
<string name="saved_devices_label_placeholder">Etiqueta</string>
|
||||
<string name="saved_devices_label_save">Guardar</string>
|
||||
<string name="saved_devices_label_clear">Borrar etiqueta</string>
|
||||
<string name="saved_devices_pending_outgoing">Esperando al otro dispositivo</string>
|
||||
<string name="saved_devices_pending_incoming">Quiere recordar este dispositivo</string>
|
||||
<string name="saved_devices_send_started">Oferta de transferencia enviada</string>
|
||||
<string name="saved_devices_forgotten">Dispositivo guardado olvidado</string>
|
||||
<string name="saved_devices_blocked">Dispositivo bloqueado</string>
|
||||
<string name="saved_devices_labeled">Etiqueta actualizada</string>
|
||||
<plurals name="transfer_file_count">
|
||||
<item quantity="one">%1$d archivo</item>
|
||||
<item quantity="other">%1$d archivos</item>
|
||||
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="storage_total">Total</string>
|
||||
<string name="storage_transfer_data">Données de transfert</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_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>
|
||||
@@ -345,31 +347,31 @@
|
||||
<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 l’enregistrement avant un autre transfert direct.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Oublier l’appareil ?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
<string name="saved_devices_pending_title">Pending pairing</string>
|
||||
<string name="saved_devices_list_title">Saved devices</string>
|
||||
<string name="saved_devices_eligibility_title">Prêt à être mémorisé</string>
|
||||
<string name="saved_devices_pending_title">Association en attente</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_loading">Chargement des appareils enregistrés…</string>
|
||||
<string name="saved_devices_more_actions">Plus d’actions pour %1$s</string>
|
||||
<string name="saved_devices_no_pending">Aucune demande d’association ne nécessite votre attention.</string>
|
||||
<string name="saved_devices_unnamed">Saved device</string>
|
||||
<string name="saved_devices_remember_action">Remember</string>
|
||||
<string name="saved_devices_decline_action">Decline</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accept</string>
|
||||
<string name="saved_devices_send_action">Send files</string>
|
||||
<string name="saved_devices_label_action">Label</string>
|
||||
<string name="saved_devices_forget_action">Forget</string>
|
||||
<string name="saved_devices_block_action">Block</string>
|
||||
<string name="saved_devices_label_title">Device label</string>
|
||||
<string name="saved_devices_label_placeholder">Label</string>
|
||||
<string name="saved_devices_label_save">Save</string>
|
||||
<string name="saved_devices_label_clear">Clear label</string>
|
||||
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
|
||||
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
|
||||
<string name="saved_devices_send_started">Transfer offer sent</string>
|
||||
<string name="saved_devices_forgotten">Saved device forgotten</string>
|
||||
<string name="saved_devices_blocked">Device blocked</string>
|
||||
<string name="saved_devices_labeled">Label updated</string>
|
||||
<string name="saved_devices_unnamed">Appareil enregistré</string>
|
||||
<string name="saved_devices_remember_action">Mémoriser</string>
|
||||
<string name="saved_devices_decline_action">Refuser</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accepter</string>
|
||||
<string name="saved_devices_send_action">Envoyer des fichiers</string>
|
||||
<string name="saved_devices_label_action">Libellé</string>
|
||||
<string name="saved_devices_forget_action">Oublier</string>
|
||||
<string name="saved_devices_block_action">Bloquer</string>
|
||||
<string name="saved_devices_label_title">Libellé de l’appareil</string>
|
||||
<string name="saved_devices_label_placeholder">Libellé</string>
|
||||
<string name="saved_devices_label_save">Enregistrer</string>
|
||||
<string name="saved_devices_label_clear">Effacer le libellé</string>
|
||||
<string name="saved_devices_pending_outgoing">En attente de l’autre appareil</string>
|
||||
<string name="saved_devices_pending_incoming">Souhaite mémoriser cet appareil</string>
|
||||
<string name="saved_devices_send_started">Proposition de transfert envoyée</string>
|
||||
<string name="saved_devices_forgotten">Appareil enregistré oublié</string>
|
||||
<string name="saved_devices_blocked">Appareil bloqué</string>
|
||||
<string name="saved_devices_labeled">Libellé mis à jour</string>
|
||||
<plurals name="transfer_file_count">
|
||||
<item quantity="one">%1$d fichier</item>
|
||||
<item quantity="other">%1$d fichiers</item>
|
||||
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="storage_total">Totale</string>
|
||||
<string name="storage_transfer_data">Dati di trasferimento</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_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>
|
||||
@@ -345,31 +347,31 @@
|
||||
<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_title">Dimenticare il dispositivo?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
<string name="saved_devices_pending_title">Pending pairing</string>
|
||||
<string name="saved_devices_list_title">Saved devices</string>
|
||||
<string name="saved_devices_eligibility_title">Pronto da ricordare</string>
|
||||
<string name="saved_devices_pending_title">Associazione in sospeso</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_loading">Caricamento dei dispositivi salvati…</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_unnamed">Saved device</string>
|
||||
<string name="saved_devices_remember_action">Remember</string>
|
||||
<string name="saved_devices_decline_action">Decline</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accept</string>
|
||||
<string name="saved_devices_send_action">Send files</string>
|
||||
<string name="saved_devices_label_action">Label</string>
|
||||
<string name="saved_devices_forget_action">Forget</string>
|
||||
<string name="saved_devices_block_action">Block</string>
|
||||
<string name="saved_devices_label_title">Device label</string>
|
||||
<string name="saved_devices_label_placeholder">Label</string>
|
||||
<string name="saved_devices_label_save">Save</string>
|
||||
<string name="saved_devices_label_clear">Clear label</string>
|
||||
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
|
||||
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
|
||||
<string name="saved_devices_send_started">Transfer offer sent</string>
|
||||
<string name="saved_devices_forgotten">Saved device forgotten</string>
|
||||
<string name="saved_devices_blocked">Device blocked</string>
|
||||
<string name="saved_devices_labeled">Label updated</string>
|
||||
<string name="saved_devices_unnamed">Dispositivo salvato</string>
|
||||
<string name="saved_devices_remember_action">Ricorda</string>
|
||||
<string name="saved_devices_decline_action">Rifiuta</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accetta</string>
|
||||
<string name="saved_devices_send_action">Invia file</string>
|
||||
<string name="saved_devices_label_action">Etichetta</string>
|
||||
<string name="saved_devices_forget_action">Dimentica</string>
|
||||
<string name="saved_devices_block_action">Blocca</string>
|
||||
<string name="saved_devices_label_title">Etichetta del dispositivo</string>
|
||||
<string name="saved_devices_label_placeholder">Etichetta</string>
|
||||
<string name="saved_devices_label_save">Salva</string>
|
||||
<string name="saved_devices_label_clear">Cancella etichetta</string>
|
||||
<string name="saved_devices_pending_outgoing">In attesa dell’altro dispositivo</string>
|
||||
<string name="saved_devices_pending_incoming">Vuole ricordare questo dispositivo</string>
|
||||
<string name="saved_devices_send_started">Proposta di trasferimento inviata</string>
|
||||
<string name="saved_devices_forgotten">Dispositivo salvato dimenticato</string>
|
||||
<string name="saved_devices_blocked">Dispositivo bloccato</string>
|
||||
<string name="saved_devices_labeled">Etichetta aggiornata</string>
|
||||
<plurals name="transfer_file_count">
|
||||
<item quantity="one">%1$d file</item>
|
||||
<item quantity="other">%1$d file</item>
|
||||
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="storage_total">Totaal</string>
|
||||
<string name="storage_transfer_data">Overdrachtsgegevens</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_title">Activiteit</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_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_eligibility_title">Ready to remember</string>
|
||||
<string name="saved_devices_pending_title">Pending pairing</string>
|
||||
<string name="saved_devices_list_title">Saved devices</string>
|
||||
<string name="saved_devices_eligibility_title">Klaar om te onthouden</string>
|
||||
<string name="saved_devices_pending_title">Koppeling in behandeling</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_loading">Opgeslagen apparaten laden…</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_unnamed">Saved device</string>
|
||||
<string name="saved_devices_remember_action">Remember</string>
|
||||
<string name="saved_devices_decline_action">Decline</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accept</string>
|
||||
<string name="saved_devices_send_action">Send files</string>
|
||||
<string name="saved_devices_unnamed">Opgeslagen apparaat</string>
|
||||
<string name="saved_devices_remember_action">Onthouden</string>
|
||||
<string name="saved_devices_decline_action">Weigeren</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accepteren</string>
|
||||
<string name="saved_devices_send_action">Bestanden versturen</string>
|
||||
<string name="saved_devices_label_action">Label</string>
|
||||
<string name="saved_devices_forget_action">Forget</string>
|
||||
<string name="saved_devices_block_action">Block</string>
|
||||
<string name="saved_devices_label_title">Device label</string>
|
||||
<string name="saved_devices_forget_action">Vergeten</string>
|
||||
<string name="saved_devices_block_action">Blokkeren</string>
|
||||
<string name="saved_devices_label_title">Apparaatlabel</string>
|
||||
<string name="saved_devices_label_placeholder">Label</string>
|
||||
<string name="saved_devices_label_save">Save</string>
|
||||
<string name="saved_devices_label_clear">Clear label</string>
|
||||
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
|
||||
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
|
||||
<string name="saved_devices_send_started">Transfer offer sent</string>
|
||||
<string name="saved_devices_forgotten">Saved device forgotten</string>
|
||||
<string name="saved_devices_blocked">Device blocked</string>
|
||||
<string name="saved_devices_labeled">Label updated</string>
|
||||
<string name="saved_devices_label_save">Opslaan</string>
|
||||
<string name="saved_devices_label_clear">Label wissen</string>
|
||||
<string name="saved_devices_pending_outgoing">Wachten op het andere apparaat</string>
|
||||
<string name="saved_devices_pending_incoming">Wil dit apparaat onthouden</string>
|
||||
<string name="saved_devices_send_started">Overdrachtsaanbod verstuurd</string>
|
||||
<string name="saved_devices_forgotten">Opgeslagen apparaat vergeten</string>
|
||||
<string name="saved_devices_blocked">Apparaat geblokkeerd</string>
|
||||
<string name="saved_devices_labeled">Label bijgewerkt</string>
|
||||
<plurals name="transfer_file_count">
|
||||
<item quantity="one">%1$d bestand</item>
|
||||
<item quantity="other">%1$d bestanden</item>
|
||||
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="storage_total">Łącznie</string>
|
||||
<string name="storage_transfer_data">Dane transferu</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_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>
|
||||
@@ -345,31 +347,31 @@
|
||||
<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_title">Zapomnieć urządzenie?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
<string name="saved_devices_pending_title">Pending pairing</string>
|
||||
<string name="saved_devices_list_title">Saved devices</string>
|
||||
<string name="saved_devices_eligibility_title">Gotowe do zapamiętania</string>
|
||||
<string name="saved_devices_pending_title">Oczekujące sparowanie</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_loading">Wczytywanie zapisanych urządzeń…</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_unnamed">Saved device</string>
|
||||
<string name="saved_devices_remember_action">Remember</string>
|
||||
<string name="saved_devices_decline_action">Decline</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accept</string>
|
||||
<string name="saved_devices_send_action">Send files</string>
|
||||
<string name="saved_devices_label_action">Label</string>
|
||||
<string name="saved_devices_forget_action">Forget</string>
|
||||
<string name="saved_devices_block_action">Block</string>
|
||||
<string name="saved_devices_label_title">Device label</string>
|
||||
<string name="saved_devices_label_placeholder">Label</string>
|
||||
<string name="saved_devices_label_save">Save</string>
|
||||
<string name="saved_devices_label_clear">Clear label</string>
|
||||
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
|
||||
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
|
||||
<string name="saved_devices_send_started">Transfer offer sent</string>
|
||||
<string name="saved_devices_forgotten">Saved device forgotten</string>
|
||||
<string name="saved_devices_blocked">Device blocked</string>
|
||||
<string name="saved_devices_labeled">Label updated</string>
|
||||
<string name="saved_devices_unnamed">Zapisane urządzenie</string>
|
||||
<string name="saved_devices_remember_action">Zapamiętaj</string>
|
||||
<string name="saved_devices_decline_action">Odrzuć</string>
|
||||
<string name="saved_devices_accept_pairing_action">Akceptuj</string>
|
||||
<string name="saved_devices_send_action">Wyślij pliki</string>
|
||||
<string name="saved_devices_label_action">Etykieta</string>
|
||||
<string name="saved_devices_forget_action">Zapomnij</string>
|
||||
<string name="saved_devices_block_action">Zablokuj</string>
|
||||
<string name="saved_devices_label_title">Etykieta urządzenia</string>
|
||||
<string name="saved_devices_label_placeholder">Etykieta</string>
|
||||
<string name="saved_devices_label_save">Zapisz</string>
|
||||
<string name="saved_devices_label_clear">Wyczyść etykietę</string>
|
||||
<string name="saved_devices_pending_outgoing">Oczekiwanie na drugie urządzenie</string>
|
||||
<string name="saved_devices_pending_incoming">Chce zapamiętać to urządzenie</string>
|
||||
<string name="saved_devices_send_started">Wysłano propozycję transferu</string>
|
||||
<string name="saved_devices_forgotten">Zapomniano zapisane urządzenie</string>
|
||||
<string name="saved_devices_blocked">Urządzenie zablokowane</string>
|
||||
<string name="saved_devices_labeled">Zaktualizowano etykietę</string>
|
||||
<plurals name="transfer_file_count">
|
||||
<item quantity="one">%1$d plik</item>
|
||||
<item quantity="few">%1$d pliki</item>
|
||||
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="storage_total">Total</string>
|
||||
<string name="storage_transfer_data">Dados de transferência</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_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>
|
||||
@@ -345,31 +347,31 @@
|
||||
<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_title">Esquecer dispositivo?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
<string name="saved_devices_pending_title">Pending pairing</string>
|
||||
<string name="saved_devices_list_title">Saved devices</string>
|
||||
<string name="saved_devices_eligibility_title">Pronto para lembrar</string>
|
||||
<string name="saved_devices_pending_title">Emparelhamento pendente</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_loading">A carregar dispositivos guardados…</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_unnamed">Saved device</string>
|
||||
<string name="saved_devices_remember_action">Remember</string>
|
||||
<string name="saved_devices_decline_action">Decline</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accept</string>
|
||||
<string name="saved_devices_send_action">Send files</string>
|
||||
<string name="saved_devices_label_action">Label</string>
|
||||
<string name="saved_devices_forget_action">Forget</string>
|
||||
<string name="saved_devices_block_action">Block</string>
|
||||
<string name="saved_devices_label_title">Device label</string>
|
||||
<string name="saved_devices_label_placeholder">Label</string>
|
||||
<string name="saved_devices_label_save">Save</string>
|
||||
<string name="saved_devices_label_clear">Clear label</string>
|
||||
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
|
||||
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
|
||||
<string name="saved_devices_send_started">Transfer offer sent</string>
|
||||
<string name="saved_devices_forgotten">Saved device forgotten</string>
|
||||
<string name="saved_devices_blocked">Device blocked</string>
|
||||
<string name="saved_devices_labeled">Label updated</string>
|
||||
<string name="saved_devices_unnamed">Dispositivo guardado</string>
|
||||
<string name="saved_devices_remember_action">Lembrar</string>
|
||||
<string name="saved_devices_decline_action">Recusar</string>
|
||||
<string name="saved_devices_accept_pairing_action">Aceitar</string>
|
||||
<string name="saved_devices_send_action">Enviar ficheiros</string>
|
||||
<string name="saved_devices_label_action">Etiqueta</string>
|
||||
<string name="saved_devices_forget_action">Esquecer</string>
|
||||
<string name="saved_devices_block_action">Bloquear</string>
|
||||
<string name="saved_devices_label_title">Etiqueta do dispositivo</string>
|
||||
<string name="saved_devices_label_placeholder">Etiqueta</string>
|
||||
<string name="saved_devices_label_save">Guardar</string>
|
||||
<string name="saved_devices_label_clear">Limpar etiqueta</string>
|
||||
<string name="saved_devices_pending_outgoing">A aguardar o outro dispositivo</string>
|
||||
<string name="saved_devices_pending_incoming">Quer lembrar este dispositivo</string>
|
||||
<string name="saved_devices_send_started">Proposta de transferência enviada</string>
|
||||
<string name="saved_devices_forgotten">Dispositivo guardado esquecido</string>
|
||||
<string name="saved_devices_blocked">Dispositivo bloqueado</string>
|
||||
<string name="saved_devices_labeled">Etiqueta atualizada</string>
|
||||
<plurals name="transfer_file_count">
|
||||
<item quantity="one">%1$d ficheiro</item>
|
||||
<item quantity="other">%1$d ficheiros</item>
|
||||
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="storage_total">Всего</string>
|
||||
<string name="storage_transfer_data">Данные передачи</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_title">Активность</string>
|
||||
<string name="transfer_delete_description">Общий доступ к «%1$s» будет остановлен, а история передачи будет удалена с этого устройства.</string>
|
||||
@@ -345,31 +347,31 @@
|
||||
<string name="saved_devices_empty_title">Нет сохранённых устройств</string>
|
||||
<string name="saved_devices_forget_confirm_body">Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны.</string>
|
||||
<string name="saved_devices_forget_confirm_title">Забыть устройство?</string>
|
||||
<string name="saved_devices_eligibility_title">Ready to remember</string>
|
||||
<string name="saved_devices_pending_title">Pending pairing</string>
|
||||
<string name="saved_devices_list_title">Saved devices</string>
|
||||
<string name="saved_devices_eligibility_title">Готово к запоминанию</string>
|
||||
<string name="saved_devices_pending_title">Ожидающее сопряжение</string>
|
||||
<string name="saved_devices_list_title">Сохранённые устройства</string>
|
||||
<string name="saved_devices_load_failed">Не удалось загрузить сохранённые устройства.</string>
|
||||
<string name="saved_devices_loading">Загрузка сохранённых устройств…</string>
|
||||
<string name="saved_devices_more_actions">Другие действия для %1$s</string>
|
||||
<string name="saved_devices_no_pending">Нет запросов на сопряжение, требующих внимания.</string>
|
||||
<string name="saved_devices_unnamed">Saved device</string>
|
||||
<string name="saved_devices_remember_action">Remember</string>
|
||||
<string name="saved_devices_decline_action">Decline</string>
|
||||
<string name="saved_devices_accept_pairing_action">Accept</string>
|
||||
<string name="saved_devices_send_action">Send files</string>
|
||||
<string name="saved_devices_label_action">Label</string>
|
||||
<string name="saved_devices_forget_action">Forget</string>
|
||||
<string name="saved_devices_block_action">Block</string>
|
||||
<string name="saved_devices_label_title">Device label</string>
|
||||
<string name="saved_devices_label_placeholder">Label</string>
|
||||
<string name="saved_devices_label_save">Save</string>
|
||||
<string name="saved_devices_label_clear">Clear label</string>
|
||||
<string name="saved_devices_pending_outgoing">Waiting for the other device</string>
|
||||
<string name="saved_devices_pending_incoming">Wants to remember this device</string>
|
||||
<string name="saved_devices_send_started">Transfer offer sent</string>
|
||||
<string name="saved_devices_forgotten">Saved device forgotten</string>
|
||||
<string name="saved_devices_blocked">Device blocked</string>
|
||||
<string name="saved_devices_labeled">Label updated</string>
|
||||
<string name="saved_devices_unnamed">Сохранённое устройство</string>
|
||||
<string name="saved_devices_remember_action">Запомнить</string>
|
||||
<string name="saved_devices_decline_action">Отклонить</string>
|
||||
<string name="saved_devices_accept_pairing_action">Принять</string>
|
||||
<string name="saved_devices_send_action">Отправить файлы</string>
|
||||
<string name="saved_devices_label_action">Метка</string>
|
||||
<string name="saved_devices_forget_action">Забыть</string>
|
||||
<string name="saved_devices_block_action">Заблокировать</string>
|
||||
<string name="saved_devices_label_title">Метка устройства</string>
|
||||
<string name="saved_devices_label_placeholder">Метка</string>
|
||||
<string name="saved_devices_label_save">Сохранить</string>
|
||||
<string name="saved_devices_label_clear">Очистить метку</string>
|
||||
<string name="saved_devices_pending_outgoing">Ожидание другого устройства</string>
|
||||
<string name="saved_devices_pending_incoming">Хочет запомнить это устройство</string>
|
||||
<string name="saved_devices_send_started">Предложение передачи отправлено</string>
|
||||
<string name="saved_devices_forgotten">Сохранённое устройство забыто</string>
|
||||
<string name="saved_devices_blocked">Устройство заблокировано</string>
|
||||
<string name="saved_devices_labeled">Метка обновлена</string>
|
||||
<plurals name="transfer_file_count">
|
||||
<item quantity="one">%1$d файл</item>
|
||||
<item quantity="few">%1$d файла</item>
|
||||
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="storage_total">Total</string>
|
||||
<string name="storage_transfer_data">Transfer data</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_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>
|
||||
|
||||
Reference in New Issue
Block a user