From b1b8fa202c39d71413fb23743ba1b7eeccff28a2 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:39:34 +0200 Subject: [PATCH] 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 --- apple/VniDrop/App/AppEnvironment.swift | 1 + apple/VniDrop/Core/AppPreferences.swift | 4 +-- apple/VniDrop/Core/CoreModels.swift | 26 +++++++++---------- apple/VniDrop/Core/CoreRepository.swift | 23 +++++++++------- apple/VniDrop/Core/FileSystemService.swift | 3 ++- .../Receive/ReceiveInvitationActions.swift | 2 +- .../Features/Send/TransferComposer.swift | 4 +-- .../Features/Settings/BugReportService.swift | 1 + .../Features/Settings/SettingsSections.swift | 10 +++---- .../ReceiveInvitationActions+iOS.swift | 16 ++++++++---- .../Platform/TransferShareActions+iOS.swift | 11 +++++--- apple/project.yml | 3 ++- 12 files changed, 61 insertions(+), 43 deletions(-) diff --git a/apple/VniDrop/App/AppEnvironment.swift b/apple/VniDrop/App/AppEnvironment.swift index 0a78e00..c89c415 100644 --- a/apple/VniDrop/App/AppEnvironment.swift +++ b/apple/VniDrop/App/AppEnvironment.swift @@ -17,6 +17,7 @@ struct DeviceInfo { let batteryLevel: String? } +@MainActor protocol DeviceInfoProvider { func load() async -> DeviceInfo } diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index 1dca135..74049c0 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -2,12 +2,12 @@ import Foundation import Combine /// Receive-destination descriptor, ported from `core/FileSystemService.kt`. -enum ReceiveFolderKind: String, Codable { +enum ReceiveFolderKind: String, Codable, Sendable { case fileSystemPath case iosSecurityScopedUrl } -struct ReceiveFolder: Equatable, Codable { +struct ReceiveFolder: Equatable, Codable, Sendable { let kind: ReceiveFolderKind let value: String let displayName: String diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index 74a1a23..33fe647 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -4,13 +4,13 @@ import Foundation /// the generated UniFFI records/enums into these so the UI never depends on the /// binding surface directly. -struct CoreStatus: Equatable { +struct CoreStatus: Equatable, Sendable { let endpointId: String let activeTransfers: UInt64 let activeShares: UInt64 } -struct CoreEventModel: Equatable, Identifiable { +struct CoreEventModel: Equatable, Identifiable, Sendable { let id: String let timestamp: Int64 let scope: String @@ -21,17 +21,17 @@ struct CoreEventModel: Equatable, Identifiable { let dataJson: String } -enum ShareAccessPolicy: Equatable { +enum ShareAccessPolicy: Equatable, Sendable { case requireApproval case anyoneWithTransfer } -enum TransferDirection: Equatable { +enum TransferDirection: Equatable, Sendable { case send case receive } -enum TransferStatus: Equatable { +enum TransferStatus: Equatable, Sendable { case importing case sharing case receiving @@ -41,7 +41,7 @@ enum TransferStatus: Equatable { case stopped } -struct Transfer: Equatable, Identifiable { +struct Transfer: Equatable, Identifiable, Sendable { let localId: String let transferId: UInt64 let direction: TransferDirection @@ -59,7 +59,7 @@ struct Transfer: Equatable, Identifiable { var id: String { localId } } -struct Share: Equatable { +struct Share: Equatable, Sendable { let transferId: UInt64 let ticket: String let transferName: String @@ -68,7 +68,7 @@ struct Share: Equatable { let totalSize: UInt64 } -struct TransferMetadataModel: Equatable { +struct TransferMetadataModel: Equatable, Sendable { let transferId: UInt64 let transferName: String let senderName: String? @@ -77,12 +77,12 @@ struct TransferMetadataModel: Equatable { let totalSize: UInt64 } -struct TicketInspectionModel: Equatable { +struct TicketInspectionModel: Equatable, Sendable { let kind: String let metadata: TransferMetadataModel } -enum ReceiverDeliveryStatus: Equatable { +enum ReceiverDeliveryStatus: Equatable, Sendable { case requested case accepted case refused @@ -91,7 +91,7 @@ enum ReceiverDeliveryStatus: Equatable { case unknown } -struct ReceiverRequestModel: Equatable, Identifiable { +struct ReceiverRequestModel: Equatable, Identifiable, Sendable { let id: String let transferId: UInt64 let remoteEndpointId: String @@ -106,7 +106,7 @@ struct ReceiverRequestModel: Equatable, Identifiable { let completedAt: Int64? } -struct CoreState: Equatable { +struct CoreState: Equatable, Sendable { var isInitialized: Bool = false var status: CoreStatus? var events: [CoreEventModel] = [] @@ -116,7 +116,7 @@ struct CoreState: Equatable { } /// Coalesced change hints emitted from the event sink, ported from `CoreSignal`. -enum CoreSignal: Equatable { +enum CoreSignal: Equatable, Sendable { case approvalChanged(transferId: UInt64) case receiverHistoryChanged(transferId: UInt64) /// Transfer status/history changed enough to re-read the durable snapshot. diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index a720408..3c3dc69 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -1,6 +1,6 @@ import Foundation import Combine -import VnidropCore +@preconcurrency import VnidropCore /// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the /// 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. var signals: AnyPublisher { 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 lazy var sink = RepositoryEventSink { [weak self] event in Task { @MainActor in self?.handle(event: event) } } - private static let maxEvents = 200 + private nonisolated static let maxEvents = 200 // MARK: - Lifecycle @@ -186,7 +189,7 @@ final class CoreRepository: ObservableObject { // MARK: - Internals /// Snapshot of the values read from the core in one pass. - private struct CoreSnapshot { + private struct CoreSnapshot: Sendable { let status: CoreStatus let transfers: [Transfer] 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 /// FFI reads); does not touch `@Published` state. - private func readSnapshot() -> CoreSnapshot? { + private nonisolated func readSnapshot() -> CoreSnapshot? { guard let core = self.core else { return nil } let status = core.status() - let transfers = (try? core.listTransfers())?.map { $0.toModel() } ?? state.transfers - let events = (try? core.listEvents(transferId: nil))?.prefix(Self.maxEvents).map { $0.toModel() } ?? state.events + let transfers = (try? core.listTransfers())?.map { $0.toModel() } ?? [] + let events = (try? core.listEvents(transferId: nil))?.prefix(Self.maxEvents).map { $0.toModel() } ?? [] return CoreSnapshot( status: CoreStatus( endpointId: status.endpointId, @@ -221,7 +224,7 @@ final class CoreRepository: ObservableObject { if let snapshot = readSnapshot() { applySnapshot(snapshot) } } - private func requireCore() throws -> VnidropCore { + private nonisolated func requireCore() throws -> VnidropCore { guard let core = self.core else { 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. - private func runCore(_ block: @escaping () throws -> T) async -> Result { + private nonisolated func runCore(_ block: @escaping @Sendable () throws -> T) async -> Result { await withCheckedContinuation { continuation in queue.async { let result: Result @@ -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)) } } diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift index 398c61e..6d3d9d0 100644 --- a/apple/VniDrop/Core/FileSystemService.swift +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -3,7 +3,7 @@ import VnidropCore /// A file/folder selected for sharing, ported from `PickedShareFile` in /// `core/FilePicker.kt`. -struct PickedShareFile: Equatable, Identifiable { +struct PickedShareFile: Equatable, Identifiable, Sendable { let value: String let displayName: String var sizeBytes: UInt64? = nil @@ -18,6 +18,7 @@ struct PickedShareFile: Equatable, Identifiable { /// Receive-destination and share-source platform bridge, ported from /// `core/FileSystemService.kt` and its iOS/desktop actuals. +@MainActor protocol FileSystemService { var supportsCustomReceiveFolders: Bool { get } diff --git a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift index de265f0..06e4db8 100644 --- a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift +++ b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift @@ -118,7 +118,7 @@ struct InvitationReviewPanel: View { .background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14)) 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")) .font(VniType.bodySmall) .foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault) diff --git a/apple/VniDrop/Features/Send/TransferComposer.swift b/apple/VniDrop/Features/Send/TransferComposer.swift index 4f7acc8..6404bf8 100644 --- a/apple/VniDrop/Features/Send/TransferComposer.swift +++ b/apple/VniDrop/Features/Send/TransferComposer.swift @@ -52,9 +52,9 @@ struct TransferComposer: View { ) } 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"), - value: Binding(get: { state.senderName }, set: model.setSenderName)) + value: Binding(get: { state.senderName }, set: { model.setSenderName($0) })) Text(LocalizedStringKey("send_access_title")).font(.headline) PolicyOption( icon: "checkmark.shield", titleKey: "send_access_approval", descKey: "send_access_approval_description", diff --git a/apple/VniDrop/Features/Settings/BugReportService.swift b/apple/VniDrop/Features/Settings/BugReportService.swift index f66f51d..5bdfad9 100644 --- a/apple/VniDrop/Features/Settings/BugReportService.swift +++ b/apple/VniDrop/Features/Settings/BugReportService.swift @@ -11,6 +11,7 @@ struct BugReportDraft { /// Bug-report submission. The full diagnostics transport (URLSession + build /// config) lands in the diagnostics phase; this protocol is the stable seam. +@MainActor protocol BugReportService { func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result func previewLogBytes() async -> Int diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index 94e5528..746957a 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -9,7 +9,7 @@ struct PreferencesSettings: View { var body: some View { Section(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 { Section(String(localized: "preferences_receive_folder_title")) { @@ -228,25 +228,25 @@ struct BugReportSettings: View { var body: some View { 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) .lineLimit(3, reservesSpace: true) .labelsHidden() } 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) .lineLimit(3, reservesSpace: true) .labelsHidden() } 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) .lineLimit(3, reservesSpace: true) .labelsHidden() } 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"))) .labelsHidden() } diff --git a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift index 8b19538..ba0ba3f 100644 --- a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift +++ b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift @@ -1,7 +1,7 @@ #if os(iOS) import UIKit -import AVFoundation -import CoreNFC +@preconcurrency import AVFoundation +@preconcurrency import CoreNFC import UniformTypeIdentifiers @MainActor @@ -98,6 +98,8 @@ final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UID case .authorized: completion(true) case .notDetermined: + // The permission callback is delivered back on the main queue. + nonisolated(unsafe) let completion = completion AVCaptureDevice.requestAccess(for: .video) { granted in DispatchQueue.main.async { completion(granted) } } @@ -183,11 +185,14 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb 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 .compactMap { $0 as? AVMetadataMachineReadableCodeObject } .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) { @@ -203,7 +208,8 @@ final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputOb } /// 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) -> Void private var session: NFCNDEFReaderSession? private var finished = false diff --git a/apple/VniDrop/Platform/TransferShareActions+iOS.swift b/apple/VniDrop/Platform/TransferShareActions+iOS.swift index 2c0a16a..2f4229c 100644 --- a/apple/VniDrop/Platform/TransferShareActions+iOS.swift +++ b/apple/VniDrop/Platform/TransferShareActions+iOS.swift @@ -1,6 +1,6 @@ #if os(iOS) import UIKit -import CoreNFC +@preconcurrency import CoreNFC @MainActor func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() } @@ -63,7 +63,8 @@ final class IosTransferShareActions: NSObject, TransferShareActions { /// Writes a VniDrop invitation to a writable NDEF tag, ported from /// `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 onResult: (Result) -> Void private var session: NFCNDEFReaderSession? @@ -95,9 +96,13 @@ final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate { func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {} 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"))) } + // 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 guard let self else { return } if let connectError { return self.finish(.failure(connectError)) } diff --git a/apple/project.yml b/apple/project.yml index b0de8fc..e4042d4 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -37,7 +37,8 @@ targets: # (the editor reads these build settings, not the manual plist). INFOPLIST_KEY_CFBundleDisplayName: VniDrop INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.utilities - SWIFT_VERSION: "5.9" + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete ENABLE_USER_SCRIPT_SANDBOXING: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon CODE_SIGN_STYLE: Automatic