refactor(apple): adopt Swift 6 language mode

Enable complete strict concurrency and switch the app target to Swift 6.

- Isolate model dependency protocols to @MainActor
- Make the CoreRepository blocking-FFI bridge race-free: nonisolated(unsafe)
  core handle, nonisolated runCore/readSnapshot, @Sendable work block,
  Sendable domain models
- Fix Binding method-reference captures; @preconcurrency imports for
  CoreNFC/AVFoundation/VnidropCore; isolate the NFC/QR delegate helpers
This commit is contained in:
2026-07-19 18:39:34 +02:00
parent bfa489def1
commit b1b8fa202c
12 changed files with 61 additions and 43 deletions

View File

@@ -17,6 +17,7 @@ struct DeviceInfo {
let batteryLevel: String? let batteryLevel: String?
} }
@MainActor
protocol DeviceInfoProvider { protocol DeviceInfoProvider {
func load() async -> DeviceInfo func load() async -> DeviceInfo
} }

View File

@@ -2,12 +2,12 @@ import Foundation
import Combine import Combine
/// Receive-destination descriptor, ported from `core/FileSystemService.kt`. /// Receive-destination descriptor, ported from `core/FileSystemService.kt`.
enum ReceiveFolderKind: String, Codable { enum ReceiveFolderKind: String, Codable, Sendable {
case fileSystemPath case fileSystemPath
case iosSecurityScopedUrl case iosSecurityScopedUrl
} }
struct ReceiveFolder: Equatable, Codable { struct ReceiveFolder: Equatable, Codable, Sendable {
let kind: ReceiveFolderKind let kind: ReceiveFolderKind
let value: String let value: String
let displayName: String let displayName: String

View File

@@ -4,13 +4,13 @@ import Foundation
/// the generated UniFFI records/enums into these so the UI never depends on the /// the generated UniFFI records/enums into these so the UI never depends on the
/// binding surface directly. /// binding surface directly.
struct CoreStatus: Equatable { struct CoreStatus: Equatable, Sendable {
let endpointId: String let endpointId: String
let activeTransfers: UInt64 let activeTransfers: UInt64
let activeShares: UInt64 let activeShares: UInt64
} }
struct CoreEventModel: Equatable, Identifiable { struct CoreEventModel: Equatable, Identifiable, Sendable {
let id: String let id: String
let timestamp: Int64 let timestamp: Int64
let scope: String let scope: String
@@ -21,17 +21,17 @@ struct CoreEventModel: Equatable, Identifiable {
let dataJson: String let dataJson: String
} }
enum ShareAccessPolicy: Equatable { enum ShareAccessPolicy: Equatable, Sendable {
case requireApproval case requireApproval
case anyoneWithTransfer case anyoneWithTransfer
} }
enum TransferDirection: Equatable { enum TransferDirection: Equatable, Sendable {
case send case send
case receive case receive
} }
enum TransferStatus: Equatable { enum TransferStatus: Equatable, Sendable {
case importing case importing
case sharing case sharing
case receiving case receiving
@@ -41,7 +41,7 @@ enum TransferStatus: Equatable {
case stopped case stopped
} }
struct Transfer: Equatable, Identifiable { struct Transfer: Equatable, Identifiable, Sendable {
let localId: String let localId: String
let transferId: UInt64 let transferId: UInt64
let direction: TransferDirection let direction: TransferDirection
@@ -59,7 +59,7 @@ struct Transfer: Equatable, Identifiable {
var id: String { localId } var id: String { localId }
} }
struct Share: Equatable { struct Share: Equatable, Sendable {
let transferId: UInt64 let transferId: UInt64
let ticket: String let ticket: String
let transferName: String let transferName: String
@@ -68,7 +68,7 @@ struct Share: Equatable {
let totalSize: UInt64 let totalSize: UInt64
} }
struct TransferMetadataModel: Equatable { struct TransferMetadataModel: Equatable, Sendable {
let transferId: UInt64 let transferId: UInt64
let transferName: String let transferName: String
let senderName: String? let senderName: String?
@@ -77,12 +77,12 @@ struct TransferMetadataModel: Equatable {
let totalSize: UInt64 let totalSize: UInt64
} }
struct TicketInspectionModel: Equatable { struct TicketInspectionModel: Equatable, Sendable {
let kind: String let kind: String
let metadata: TransferMetadataModel let metadata: TransferMetadataModel
} }
enum ReceiverDeliveryStatus: Equatable { enum ReceiverDeliveryStatus: Equatable, Sendable {
case requested case requested
case accepted case accepted
case refused case refused
@@ -91,7 +91,7 @@ enum ReceiverDeliveryStatus: Equatable {
case unknown case unknown
} }
struct ReceiverRequestModel: Equatable, Identifiable { struct ReceiverRequestModel: Equatable, Identifiable, Sendable {
let id: String let id: String
let transferId: UInt64 let transferId: UInt64
let remoteEndpointId: String let remoteEndpointId: String
@@ -106,7 +106,7 @@ struct ReceiverRequestModel: Equatable, Identifiable {
let completedAt: Int64? let completedAt: Int64?
} }
struct CoreState: Equatable { struct CoreState: Equatable, Sendable {
var isInitialized: Bool = false var isInitialized: Bool = false
var status: CoreStatus? var status: CoreStatus?
var events: [CoreEventModel] = [] var events: [CoreEventModel] = []
@@ -116,7 +116,7 @@ struct CoreState: Equatable {
} }
/// Coalesced change hints emitted from the event sink, ported from `CoreSignal`. /// Coalesced change hints emitted from the event sink, ported from `CoreSignal`.
enum CoreSignal: Equatable { enum CoreSignal: Equatable, Sendable {
case approvalChanged(transferId: UInt64) case approvalChanged(transferId: UInt64)
case receiverHistoryChanged(transferId: UInt64) case receiverHistoryChanged(transferId: UInt64)
/// Transfer status/history changed enough to re-read the durable snapshot. /// Transfer status/history changed enough to re-read the durable snapshot.

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
import Combine import Combine
import VnidropCore @preconcurrency import VnidropCore
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the /// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
/// generated UniFFI records into app domain models, publishes an observable /// generated UniFFI records into app domain models, publishes an observable
@@ -16,13 +16,16 @@ final class CoreRepository: ObservableObject {
/// Coalesced change hints; subscribe to react to approval/history/transfer changes. /// Coalesced change hints; subscribe to react to approval/history/transfer changes.
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() } var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
private var core: VnidropCore? // Set on the main actor (initialize/shutdown) but read from `queue` inside
// `runCore`; the underlying core is internally synchronized, so this crossing
// is safe. `nonisolated(unsafe)` documents that contract for Swift 6.
private nonisolated(unsafe) var core: VnidropCore?
private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated) private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated)
private lazy var sink = RepositoryEventSink { [weak self] event in private lazy var sink = RepositoryEventSink { [weak self] event in
Task { @MainActor in self?.handle(event: event) } Task { @MainActor in self?.handle(event: event) }
} }
private static let maxEvents = 200 private nonisolated static let maxEvents = 200
// MARK: - Lifecycle // MARK: - Lifecycle
@@ -186,7 +189,7 @@ final class CoreRepository: ObservableObject {
// MARK: - Internals // MARK: - Internals
/// Snapshot of the values read from the core in one pass. /// Snapshot of the values read from the core in one pass.
private struct CoreSnapshot { private struct CoreSnapshot: Sendable {
let status: CoreStatus let status: CoreStatus
let transfers: [Transfer] let transfers: [Transfer]
let events: [CoreEventModel] let events: [CoreEventModel]
@@ -194,11 +197,11 @@ final class CoreRepository: ObservableObject {
/// Reads the current core state. Safe to call off the main actor (pure core /// Reads the current core state. Safe to call off the main actor (pure core
/// FFI reads); does not touch `@Published` state. /// FFI reads); does not touch `@Published` state.
private func readSnapshot() -> CoreSnapshot? { private nonisolated func readSnapshot() -> CoreSnapshot? {
guard let core = self.core else { return nil } guard let core = self.core else { return nil }
let status = core.status() let status = core.status()
let transfers = (try? core.listTransfers())?.map { $0.toModel() } ?? state.transfers let transfers = (try? core.listTransfers())?.map { $0.toModel() } ?? []
let events = (try? core.listEvents(transferId: nil))?.prefix(Self.maxEvents).map { $0.toModel() } ?? state.events let events = (try? core.listEvents(transferId: nil))?.prefix(Self.maxEvents).map { $0.toModel() } ?? []
return CoreSnapshot( return CoreSnapshot(
status: CoreStatus( status: CoreStatus(
endpointId: status.endpointId, endpointId: status.endpointId,
@@ -221,7 +224,7 @@ final class CoreRepository: ObservableObject {
if let snapshot = readSnapshot() { applySnapshot(snapshot) } if let snapshot = readSnapshot() { applySnapshot(snapshot) }
} }
private func requireCore() throws -> VnidropCore { private nonisolated func requireCore() throws -> VnidropCore {
guard let core = self.core else { guard let core = self.core else {
throw InvitationError.message("Initialize the core first.") throw InvitationError.message("Initialize the core first.")
} }
@@ -229,7 +232,7 @@ final class CoreRepository: ObservableObject {
} }
/// Runs a blocking core call off the main actor and hops the result back. /// Runs a blocking core call off the main actor and hops the result back.
private func runCore<T>(_ block: @escaping () throws -> T) async -> Result<T, Error> { private nonisolated func runCore<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
queue.async { queue.async {
let result: Result<T, Error> let result: Result<T, Error>
@@ -243,7 +246,7 @@ final class CoreRepository: ObservableObject {
} }
} }
private static func nextTransferId() -> UInt64 { private nonisolated static func nextTransferId() -> UInt64 {
UInt64.random(in: 1...UInt64(Int64.max)) UInt64.random(in: 1...UInt64(Int64.max))
} }
} }

View File

@@ -3,7 +3,7 @@ import VnidropCore
/// A file/folder selected for sharing, ported from `PickedShareFile` in /// A file/folder selected for sharing, ported from `PickedShareFile` in
/// `core/FilePicker.kt`. /// `core/FilePicker.kt`.
struct PickedShareFile: Equatable, Identifiable { struct PickedShareFile: Equatable, Identifiable, Sendable {
let value: String let value: String
let displayName: String let displayName: String
var sizeBytes: UInt64? = nil var sizeBytes: UInt64? = nil
@@ -18,6 +18,7 @@ struct PickedShareFile: Equatable, Identifiable {
/// Receive-destination and share-source platform bridge, ported from /// Receive-destination and share-source platform bridge, ported from
/// `core/FileSystemService.kt` and its iOS/desktop actuals. /// `core/FileSystemService.kt` and its iOS/desktop actuals.
@MainActor
protocol FileSystemService { protocol FileSystemService {
var supportsCustomReceiveFolders: Bool { get } var supportsCustomReceiveFolders: Bool { get }

View File

@@ -118,7 +118,7 @@ struct InvitationReviewPanel: View {
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14)) .background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
Field(label: String(localized: "field_receiver_name"), Field(label: String(localized: "field_receiver_name"),
value: Binding(get: { state.receiverName }, set: model.setReceiverName)) value: Binding(get: { state.receiverName }, set: { model.setReceiverName($0) }))
Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable")) Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
.font(VniType.bodySmall) .font(VniType.bodySmall)
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault) .foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)

View File

@@ -52,9 +52,9 @@ struct TransferComposer: View {
) )
} }
Field(label: String(localized: "field_transfer_name"), Field(label: String(localized: "field_transfer_name"),
value: Binding(get: { state.transferName }, set: model.setTransferName)) value: Binding(get: { state.transferName }, set: { model.setTransferName($0) }))
Field(label: String(localized: "field_sender_name"), Field(label: String(localized: "field_sender_name"),
value: Binding(get: { state.senderName }, set: model.setSenderName)) value: Binding(get: { state.senderName }, set: { model.setSenderName($0) }))
Text(LocalizedStringKey("send_access_title")).font(.headline) Text(LocalizedStringKey("send_access_title")).font(.headline)
PolicyOption( PolicyOption(
icon: "checkmark.shield", titleKey: "send_access_approval", descKey: "send_access_approval_description", icon: "checkmark.shield", titleKey: "send_access_approval", descKey: "send_access_approval_description",

View File

@@ -11,6 +11,7 @@ struct BugReportDraft {
/// Bug-report submission. The full diagnostics transport (URLSession + build /// Bug-report submission. The full diagnostics transport (URLSession + build
/// config) lands in the diagnostics phase; this protocol is the stable seam. /// config) lands in the diagnostics phase; this protocol is the stable seam.
@MainActor
protocol BugReportService { protocol BugReportService {
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error>
func previewLogBytes() async -> Int func previewLogBytes() async -> Int

View File

@@ -9,7 +9,7 @@ struct PreferencesSettings: View {
var body: some View { var body: some View {
Section(String(localized: "field_username")) { Section(String(localized: "field_username")) {
TextField(String(localized: "field_username"), TextField(String(localized: "field_username"),
text: Binding(get: { model.state.username }, set: model.setUsername)) text: Binding(get: { model.state.username }, set: { model.setUsername($0) }))
} }
if model.state.supportsCustomReceiveFolders { if model.state.supportsCustomReceiveFolders {
Section(String(localized: "preferences_receive_folder_title")) { Section(String(localized: "preferences_receive_folder_title")) {
@@ -228,25 +228,25 @@ struct BugReportSettings: View {
var body: some View { var body: some View {
Section(String(localized: "bug_report_what_label")) { Section(String(localized: "bug_report_what_label")) {
TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: model.setBugWhatHappened), TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }),
prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical) prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical)
.lineLimit(3, reservesSpace: true) .lineLimit(3, reservesSpace: true)
.labelsHidden() .labelsHidden()
} }
Section(String(localized: "bug_report_expected_label")) { Section(String(localized: "bug_report_expected_label")) {
TextField("", text: Binding(get: { model.state.bugExpected }, set: model.setBugExpected), TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }),
prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical) prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical)
.lineLimit(3, reservesSpace: true) .lineLimit(3, reservesSpace: true)
.labelsHidden() .labelsHidden()
} }
Section(String(localized: "bug_report_steps_label")) { Section(String(localized: "bug_report_steps_label")) {
TextField("", text: Binding(get: { model.state.bugSteps }, set: model.setBugSteps), TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }),
prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical) prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical)
.lineLimit(3, reservesSpace: true) .lineLimit(3, reservesSpace: true)
.labelsHidden() .labelsHidden()
} }
Section(String(localized: "bug_report_contact_label")) { Section(String(localized: "bug_report_contact_label")) {
TextField("", text: Binding(get: { model.state.bugContact }, set: model.setBugContact), TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }),
prompt: Text(LocalizedStringKey("bug_report_contact_hint"))) prompt: Text(LocalizedStringKey("bug_report_contact_hint")))
.labelsHidden() .labelsHidden()
} }

View File

@@ -1,7 +1,7 @@
#if os(iOS) #if os(iOS)
import UIKit import UIKit
import AVFoundation @preconcurrency import AVFoundation
import CoreNFC @preconcurrency import CoreNFC
import UniformTypeIdentifiers import UniformTypeIdentifiers
@MainActor @MainActor
@@ -98,6 +98,8 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID
case .authorized: case .authorized:
completion(true) completion(true)
case .notDetermined: case .notDetermined:
// The permission callback is delivered back on the main queue.
nonisolated(unsafe) let completion = completion
AVCaptureDevice.requestAccess(for: .video) { granted in AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async { completion(granted) } DispatchQueue.main.async { completion(granted) }
} }
@@ -183,11 +185,14 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb
DispatchQueue.global(qos: .userInitiated).async { [session] in session.startRunning() } DispatchQueue.global(qos: .userInitiated).async { [session] in session.startRunning() }
} }
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { // The metadata output delegate queue is `.main`, so hop back onto the main
// actor to touch view-controller state.
nonisolated func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
let value = metadataObjects let value = metadataObjects
.compactMap { $0 as? AVMetadataMachineReadableCodeObject } .compactMap { $0 as? AVMetadataMachineReadableCodeObject }
.first { $0.type == .qr }?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .first { $0.type == .qr }?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !value.isEmpty { finish(.success(value)) } guard !value.isEmpty else { return }
MainActor.assumeIsolated { finish(.success(value)) }
} }
private func finish(_ result: Result<String, Error>) { private func finish(_ result: Result<String, Error>) {
@@ -203,7 +208,8 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb
} }
/// NFC invitation reader, ported from `InvitationNfcReader`. /// NFC invitation reader, ported from `InvitationNfcReader`.
final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate { // Runs entirely on the NFC session's `.main` delegate queue.
final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
private let onResult: (Result<String, Error>) -> Void private let onResult: (Result<String, Error>) -> Void
private var session: NFCNDEFReaderSession? private var session: NFCNDEFReaderSession?
private var finished = false private var finished = false

View File

@@ -1,6 +1,6 @@
#if os(iOS) #if os(iOS)
import UIKit import UIKit
import CoreNFC @preconcurrency import CoreNFC
@MainActor @MainActor
func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() } func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() }
@@ -63,7 +63,8 @@ final class IosTransferShareActions: NSObject, TransferShareActions {
/// Writes a VniDrop invitation to a writable NDEF tag, ported from /// Writes a VniDrop invitation to a writable NDEF tag, ported from
/// `InvitationNfcWriter` in `TransferShareActions.ios.kt`. /// `InvitationNfcWriter` in `TransferShareActions.ios.kt`.
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate { // Runs entirely on the NFC session's `.main` delegate queue.
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable {
private let ticket: String private let ticket: String
private let onResult: (Result<Void, Error>) -> Void private let onResult: (Result<Void, Error>) -> Void
private var session: NFCNDEFReaderSession? private var session: NFCNDEFReaderSession?
@@ -95,9 +96,13 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate {
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {} func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {}
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
guard let tag = tags.first else { guard let firstTag = tags.first else {
return finish(.failure(InvitationError.message("No NFC tag was detected"))) return finish(.failure(InvitationError.message("No NFC tag was detected")))
} }
// CoreNFC completion handlers run on the session's `.main` queue; these
// framework values are safe to use there.
nonisolated(unsafe) let session = session
nonisolated(unsafe) let tag = firstTag
session.connect(to: tag) { [weak self] connectError in session.connect(to: tag) { [weak self] connectError in
guard let self else { return } guard let self else { return }
if let connectError { return self.finish(.failure(connectError)) } if let connectError { return self.finish(.failure(connectError)) }

View File

@@ -37,7 +37,8 @@ targets:
# (the editor reads these build settings, not the manual plist). # (the editor reads these build settings, not the manual plist).
INFOPLIST_KEY_CFBundleDisplayName: VniDrop INFOPLIST_KEY_CFBundleDisplayName: VniDrop
INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.utilities INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.utilities
SWIFT_VERSION: "5.9" SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
ENABLE_USER_SCRIPT_SANDBOXING: NO ENABLE_USER_SCRIPT_SANDBOXING: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Automatic