mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
Adds the native SwiftUI Saved Devices experience on top of the production saved-device core, as a top-level destination in the iOS tab bar and the macOS sidebar. Core seam: - App-facing saved-device domain models mirroring core/SavedDeviceModels.kt, with lifecycle helpers (canReceive/canResume/canCancel/canDelete) so views never hand-roll state checks. - 21 gateway methods through CoreGateway/CoreRepository with UniFFI mapping. cancelTargetedTransfer, forgetSavedDevice and blockDevice run off the serial lane: each must reach the core while a targeted receive is blocking it. - Payload-free pairingChanged/targetedTransferChanged signals, dispatched before the numeric-transferId guard since saved-device events identify their subject by peer endpoint or a string transfer id. Experience: - Screen lists saved devices and outstanding consent requests only; the global targeted-transfer history stays out, reachable per device. - Details as a sheet with detents on compact layouts and a native inspector on macOS, owning Send, label, forget/block and that device's transfers. - Label editing is transactional: the draft and editor survive a failed write, conflicting actions are refused while saving, and the editor closes only after the core confirms. - Pairing and targeted-offer consent hosted at the app root, answerable from any tab and suppressed while a transfer approval is up. Dismissing a pairing prompt suppresses locally without consuming the single-use eligibility; dismissing an offer declines it, since an unanswered offer holds a slot in the core's bounded per-sender queue. - Targeted send reuses the invitation composer's affordances with file, folder, rename, replace and cleanup parity. Picker copies are released on replace/remove/clear/cancel and after a successful create, but kept after a failure so retry does not require re-picking. - Notifications for pairing requests and offers (withdrawn once answered) and for terminal targeted transfers. Wording follows direction: on the sending device the peer finished receiving, not us. Localization: - Widens 52 saved-device keys from kmp-only to both platforms. - Five keys carried a literal %1$s with no declared args, which Compose renders positionally but the Apple generator emits as a plain constant, leaking the placeholder into the UI. They now use named args; Compose output is byte-identical. - Adds targeted_offer_title/body. Reusing the invitation approval copy stated the roles backwards, announcing the sender as the receiver. Also surfaces core startup failures: the startup overlay is drawn above the snackbar host, so a failed initialize() was indistinguishable from an app that never finished loading. AppModel now keeps the reason, logs it, and the overlay shows it with a retry, plus the technical detail in DEBUG builds. Send and receive between two devices is verified only partially; a missing endpoint-identity credential currently blocks startup on the test device.
300 lines
13 KiB
Swift
300 lines
13 KiB
Swift
import Foundation
|
|
import Combine
|
|
import VnidropCore
|
|
@testable import VniDrop
|
|
|
|
enum TestError: Error { case unimplemented }
|
|
|
|
/// In-memory `CoreGateway`, mirroring `support/Fakes.kt`'s `FakeCoreGateway`.
|
|
/// Lets model tests drive core state/signals and stub results without the FFI.
|
|
@MainActor
|
|
final class FakeCoreGateway: CoreGateway {
|
|
private let stateSubject = CurrentValueSubject<CoreState, Never>(CoreState())
|
|
private let signalsSubject = PassthroughSubject<CoreSignal, Never>()
|
|
|
|
var state: CoreState { stateSubject.value }
|
|
var statePublisher: AnyPublisher<CoreState, Never> { stateSubject.eraseToAnyPublisher() }
|
|
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
|
|
|
|
// Stubbed results
|
|
var requests: [UInt64: [ReceiverRequestModel]] = [:]
|
|
var responseResult: Result<Void, Error> = .success(())
|
|
var shareResult: Result<Share, Error> = .failure(TestError.unimplemented)
|
|
var inspectionResult: Result<TicketInspectionModel, Error> = .failure(TestError.unimplemented)
|
|
var receiveResult: Result<Void, Error> = .success(())
|
|
var cancelResult: Result<Void, Error> = .success(())
|
|
var deleteResult: Result<Void, Error> = .success(())
|
|
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
|
|
var initializeResult: Result<Void, Error> = .success(())
|
|
var initializeResults: [Result<Void, Error>] = []
|
|
|
|
// Recorded calls
|
|
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
|
|
private(set) var deletedTransfers: [UInt64] = []
|
|
private(set) var cancelledTransfers: [UInt64] = []
|
|
private(set) var clearReceiveHistoryCount = 0
|
|
private(set) var receiveCount = 0
|
|
private(set) var lastReceiveTicket: String?
|
|
private(set) var lastReceiveReceiverName: String?
|
|
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
|
|
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
|
|
|
|
func setState(_ state: CoreState) { stateSubject.send(state) }
|
|
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
|
|
|
|
func initialize(
|
|
appDataDir: String,
|
|
networkConfiguration: RelayConfiguration
|
|
) async -> Result<Void, Error> {
|
|
initializedNetworkConfigurations.append(networkConfiguration)
|
|
let result = initializeResults.isEmpty ? initializeResult : initializeResults.removeFirst()
|
|
guard case .success = result else { return result }
|
|
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
|
|
return shareResult
|
|
}
|
|
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> { inspectionResult }
|
|
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
|
|
receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName
|
|
return receiveResult
|
|
}
|
|
func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result<Void, Error> {
|
|
receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName
|
|
return receiveResult
|
|
}
|
|
func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult }
|
|
func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult }
|
|
func clearReceiveHistory() async -> Result<UInt64, Error> { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult }
|
|
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
|
|
.success(CoreStorageUsageModel(blobStoreBytes: 0, appDataBytes: 0))
|
|
}
|
|
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { .success([]) }
|
|
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) }
|
|
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> {
|
|
responses.append((requestId, accepted, reason))
|
|
return responseResult
|
|
}
|
|
func refresh() async -> Result<Void, Error> { .success(()) }
|
|
|
|
// MARK: - Saved devices
|
|
|
|
// Stubbed results
|
|
var savedDevices: [SavedDeviceModel] = []
|
|
/// Overrides `savedDevices` when set, so a test can fail one leg of the
|
|
/// five-read snapshot without stubbing the rest.
|
|
var savedDevicesResult: Result<[SavedDeviceModel], Error>?
|
|
var deviceRelationships: [DeviceRelationshipModel] = []
|
|
var pairingEligibilities: [PairingEligibilityModel] = []
|
|
var blockedDevices: [String] = []
|
|
var pendingTargetedOffers: [PendingTargetedOfferModel] = []
|
|
var targetedTransfers: [TargetedTransferModel] = []
|
|
var setLabelResult: Result<Void, Error> = .success(())
|
|
var forgetResult: Result<Void, Error> = .success(())
|
|
var blockResult: Result<Void, Error> = .success(())
|
|
var requestPairingResult: Result<Bool, Error> = .success(true)
|
|
var respondToPairingResult: Result<Bool, Error> = .success(true)
|
|
var offerResponseResult: Result<TargetedOfferResponseModel, Error> = .success(.declined)
|
|
var createTargetedTransferResult: Result<TargetedTransferModel, Error> = .failure(TestError.unimplemented)
|
|
var targetedReceiveResult: Result<Void, Error> = .success(())
|
|
var targetedCancelResult: Result<Void, Error> = .success(())
|
|
var targetedDeleteResult: Result<Void, Error> = .success(())
|
|
|
|
// Recorded calls
|
|
private(set) var setLabels: [(peerEndpointId: String, label: String?)] = []
|
|
private(set) var forgottenDevices: [String] = []
|
|
private(set) var blockedCalls: [String] = []
|
|
private(set) var unblockedCalls: [String] = []
|
|
private(set) var declinedEligibilities: [String] = []
|
|
private(set) var requestedPairings: [String] = []
|
|
private(set) var pairingResponses: [(peerEndpointId: String, accepted: Bool)] = []
|
|
private(set) var offerResponses: [(transferId: String, accepted: Bool)] = []
|
|
private(set) var createdTargetedTransfers: [(receiverEndpointId: String, sources: [ShareSource], transferName: String?)] = []
|
|
private(set) var targetedReceives: [(transferId: String, outputDirectoryUrl: String)] = []
|
|
private(set) var targetedResumes: [(id: String, outputDirectoryUrl: String)] = []
|
|
private(set) var cancelledTargetedTransfers: [String] = []
|
|
private(set) var deletedTargetedTransfers: [String] = []
|
|
|
|
func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error> {
|
|
.success(pairingEligibilities)
|
|
}
|
|
func declinePairingEligibility(peerEndpointId: String) async -> Result<Void, Error> {
|
|
declinedEligibilities.append(peerEndpointId)
|
|
return .success(())
|
|
}
|
|
func requestSavedDevicePairing(peerEndpointId: String) async -> Result<Bool, Error> {
|
|
requestedPairings.append(peerEndpointId)
|
|
return requestPairingResult
|
|
}
|
|
func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
|
pairingResponses.append((peerEndpointId, accepted))
|
|
return respondToPairingResult
|
|
}
|
|
func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error> {
|
|
.success(deviceRelationships)
|
|
}
|
|
func listSavedDevices() async -> Result<[SavedDeviceModel], Error> {
|
|
savedDevicesResult ?? .success(savedDevices)
|
|
}
|
|
func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result<Void, Error> {
|
|
setLabels.append((peerEndpointId, label))
|
|
return setLabelResult
|
|
}
|
|
func forgetSavedDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
|
forgottenDevices.append(peerEndpointId)
|
|
return forgetResult
|
|
}
|
|
func blockDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
|
blockedCalls.append(peerEndpointId)
|
|
return blockResult
|
|
}
|
|
func unblockDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
|
unblockedCalls.append(peerEndpointId)
|
|
return .success(())
|
|
}
|
|
func listBlockedDevices() async -> Result<[String], Error> { .success(blockedDevices) }
|
|
|
|
// MARK: - Targeted transfers
|
|
|
|
func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error> {
|
|
.success(pendingTargetedOffers)
|
|
}
|
|
func respondToTargetedOffer(
|
|
transferId: String, accepted: Bool
|
|
) async -> Result<TargetedOfferResponseModel, Error> {
|
|
offerResponses.append((transferId, accepted))
|
|
return offerResponseResult
|
|
}
|
|
func createTargetedTransfer(
|
|
receiverEndpointId: String, sources: [ShareSource], transferName: String?
|
|
) async -> Result<TargetedTransferModel, Error> {
|
|
createdTargetedTransfers.append((receiverEndpointId, sources, transferName))
|
|
return createTargetedTransferResult
|
|
}
|
|
func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> {
|
|
.success(targetedTransfers)
|
|
}
|
|
func receiveTargetedTransfer(
|
|
transferId: String, outputDirectoryUrl: String
|
|
) async -> Result<Void, Error> {
|
|
targetedReceives.append((transferId, outputDirectoryUrl))
|
|
return targetedReceiveResult
|
|
}
|
|
func resumeTargetedTransfer(id: String, outputDirectoryUrl: String) async -> Result<Void, Error> {
|
|
targetedResumes.append((id, outputDirectoryUrl))
|
|
return targetedReceiveResult
|
|
}
|
|
func cancelTargetedTransfer(id: String) async -> Result<Void, Error> {
|
|
cancelledTargetedTransfers.append(id)
|
|
return targetedCancelResult
|
|
}
|
|
func deleteTargetedTransfer(id: String) async -> Result<Void, Error> {
|
|
deletedTargetedTransfers.append(id)
|
|
return targetedDeleteResult
|
|
}
|
|
}
|
|
|
|
/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal.
|
|
@MainActor
|
|
final class FakeFileSystemService: FileSystemService {
|
|
var supportsCustomReceiveFolders = false
|
|
var folder = ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents")
|
|
|
|
func defaultReceiveFolder() -> ReceiveFolder { folder }
|
|
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
|
|
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
|
private(set) var shareDestinations: [ShareDestination] = []
|
|
|
|
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<Share, Error> {
|
|
shareDestinations.append(destination)
|
|
guard case .invitation(let accessPolicy) = destination else {
|
|
return .failure(TestError.unimplemented)
|
|
}
|
|
return await repository.shareSources(
|
|
[], 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
|
|
final class FakeDeviceInfoProvider: DeviceInfoProvider {
|
|
func load() async -> DeviceInfo {
|
|
DeviceInfo(deviceName: "Test Device", deviceModel: "TestModel",
|
|
operatingSystem: "TestOS 1.0", network: nil, batteryLevel: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Factories
|
|
|
|
@MainActor
|
|
enum Fixtures {
|
|
static func preferences(username: String = "Tester") -> AppPreferencesRepository {
|
|
let defaults = UserDefaults(suiteName: "vnidrop.tests.\(UUID().uuidString)")!
|
|
return AppPreferencesRepository(
|
|
defaults: defaults,
|
|
fallback: AppPreferencesDefaults(
|
|
username: username,
|
|
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents"),
|
|
themeMode: .system
|
|
)
|
|
)
|
|
}
|
|
|
|
static func request(id: String, requestedAt: Int64, transferId: UInt64 = 1, status: ReceiverDeliveryStatus = .requested) -> ReceiverRequestModel {
|
|
ReceiverRequestModel(
|
|
id: id, transferId: transferId, remoteEndpointId: "endpoint-\(id)",
|
|
transferName: "Photos", receiverName: "Peer", receiverDeviceName: "Phone",
|
|
appVersion: "1.0", status: status, reason: nil,
|
|
requestedAt: requestedAt, respondedAt: nil, completedAt: nil
|
|
)
|
|
}
|
|
|
|
static func transfer(id: UInt64, direction: TransferDirection, status: TransferStatus) -> Transfer {
|
|
Transfer(
|
|
localId: "local-\(id)", transferId: id, direction: direction, status: status,
|
|
peerId: nil, transferName: "Photos", contentHash: nil, fileCount: 1, totalSize: 1024,
|
|
ticket: "ticket", accessPolicy: .requireApproval, createdAt: 0, updatedAt: 0
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Polls `condition` on the main actor until true or `timeout` elapses. Used to
|
|
/// await the models' internal `Task`s, which XCTest can't join directly.
|
|
@MainActor
|
|
func waitUntil(timeout: TimeInterval = 2, _ condition: @escaping () -> Bool) async {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while !condition() && Date() < deadline {
|
|
try? await Task.sleep(nanoseconds: 5_000_000)
|
|
}
|
|
}
|