Merge remote-tracking branch 'origin/feat/device-history' into feat/device-history-apple

# Conflicts:
#	apple/VniDrop/App/RootView.swift
#	apple/VniDrop/Features/App/AppModel.swift
This commit is contained in:
2026-08-13 22:31:38 +02:00
25 changed files with 748 additions and 32 deletions

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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,6 +39,7 @@ 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) }
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
@@ -54,6 +56,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

View File

@@ -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"))

View File

@@ -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)
}
}