conduit/ios/Runner/NativePasteBridge.swift
cogwheel efd452906d
Some checks failed
CI / Mobile app (analyze + test) (push) Has been cancelled
CI / Dart packages (push) Has been cancelled
CI / Dart packages-1 (push) Has been cancelled
CI / Dart packages-2 (push) Has been cancelled
CI / Dart packages-3 (push) Has been cancelled
CI / Web-safe packages (push) Has been cancelled
CI / Localization (push) Has been cancelled
Split AppDelegate into per-bridge files behind ConduitBridgeHost (#794)
Split ios/Runner/AppDelegate.swift into one file per bridge and put the bridges behind a host-neutral ConduitBridgeHost protocol, so each bridge can be tested and reused without the Flutter view controller. The boundary check in tool/check_package_boundaries.dart now holds every Swift file in ios/Runner to one side of that seam.

No intended behaviour change. Review follow-ups: an expiring streaming background task that keepAlive already replaced no longer reports a suspension, and the Send Image App Intent rejects files that do not resolve to an image type.
2026-10-03 21:12:07 +05:30

1587 lines
55 KiB
Swift

// FLUTTER HOST ONLY. Swizzles Flutter's FlutterTextInputView to intercept
// paste, so it is not a `ConduitBridge` and other hosts do not copy
// this file. The Flutter app delegate attaches it directly.
import Darwin
import Flutter
import Foundation
import ImageIO
import ObjectiveC.runtime
import OSLog
import UIKit
import UniformTypeIdentifiers
final class NativePasteDeliveryCompletion {
private let lock = NSLock()
private var completion: ((Bool) -> Void)?
init(completion: @escaping (Bool) -> Void) {
self.completion = completion
}
/// Returns true only to the caller that delivered the acknowledgement.
@discardableResult
func resolve(_ consumed: Bool) -> Bool {
lock.lock()
let callback = completion
completion = nil
lock.unlock()
guard let callback else { return false }
callback(consumed)
return true
}
}
enum NativePasteDeliveryDecision: Equatable {
case accepted
case rejected
case indeterminate
var suppressesFallbackPaste: Bool {
self != .rejected
}
}
struct NativePasteEditorContext: Equatable {
let documentText: String
let selectionStart: Int
let selectionEnd: Int
}
func nativePasteEditorContext(for responder: UIResponder) ->
NativePasteEditorContext? {
guard let textInput = responder as? UITextInput,
let documentRange = textInput.textRange(
from: textInput.beginningOfDocument,
to: textInput.endOfDocument
),
let documentText = textInput.text(in: documentRange),
let selection = textInput.selectedTextRange else { return nil }
return NativePasteEditorContext(
documentText: documentText,
selectionStart: textInput.offset(
from: textInput.beginningOfDocument,
to: selection.start
),
selectionEnd: textInput.offset(
from: textInput.beginningOfDocument,
to: selection.end
)
)
}
func nativePasteFallbackContextMatches(
responderIsFirstResponder: Bool,
expectedPasteboardChangeCount: Int,
currentPasteboardChangeCount: Int,
expectedEditorContext: NativePasteEditorContext?,
currentEditorContext: NativePasteEditorContext?
) -> Bool {
responderIsFirstResponder &&
expectedPasteboardChangeCount == currentPasteboardChangeCount &&
expectedEditorContext == currentEditorContext
}
enum NativePasteDeliveryMarkerState: String, CaseIterable {
case pending
case dartOwned = "dart-owned"
case reclaiming
}
enum NativePasteDeliverySettlement: Equatable {
case reclaimed
case dartOwned
case preservedUnknown
}
/// Reads an item-provider URL under file-coordination ownership. In-place
/// representations can be backed by another process (for example Files or a
/// document provider), so opening the original URL directly can race an
/// eviction or writer while the bytes are copied into app-owned staging.
func withCoordinatedNativePasteRead<T>(
at sourceURL: URL,
_ read: (URL) -> T?
) -> T? {
let didAccessSecurityScope = sourceURL.startAccessingSecurityScopedResource()
defer {
if didAccessSecurityScope {
sourceURL.stopAccessingSecurityScopedResource()
}
}
var coordinationError: NSError?
var result: T?
NSFileCoordinator(filePresenter: nil).coordinate(
readingItemAt: sourceURL,
options: .withoutChanges,
error: &coordinationError
) { coordinatedURL in
result = read(coordinatedURL)
}
guard coordinationError == nil else { return nil }
return result
}
private enum NativePasteNoFollowEntry: Equatable {
case missing
case regularFile
case directory
case other
case inaccessible
}
/// Filesystem-backed ownership handshake for native paste deliveries.
///
/// Both sides race to rename the same `.pending` marker. Dart renames it to
/// `.dart-owned` immediately before the synchronous composer mutation; native
/// iOS renames it to `.reclaiming` before deleting the batch. A rename can win
/// only once, so a delayed Pigeon reply can never make native delete files that
/// Dart already owns.
final class NativePasteDeliveryStore {
static let stagingDirectoryName = "conduit-native-paste"
static let markerPrefix = ".conduit-native-paste-v2-"
static let allowedExtensions: Set<String> = [
"bmp", "gif", "heic", "heif", "jpg", "png", "tiff", "webp",
]
let rootURL: URL
private let fileManager: FileManager
private let directoryContentsOverride: ((URL) -> [URL]?)?
private let removeItemOverride: ((URL) -> Bool)?
init(
rootURL: URL = NativePasteDeliveryStore.defaultRootURL,
fileManager: FileManager = .default,
directoryContentsForTesting: ((URL) -> [URL]?)? = nil,
removeItemForTesting: ((URL) -> Bool)? = nil
) {
self.rootURL = rootURL.standardizedFileURL
self.fileManager = fileManager
self.directoryContentsOverride = directoryContentsForTesting
self.removeItemOverride = removeItemForTesting
}
private static var defaultRootURL: URL {
let baseURL = FileManager.default.urls(
for: .cachesDirectory,
in: .userDomainMask
).first ?? FileManager.default.temporaryDirectory
return baseURL.appendingPathComponent(
stagingDirectoryName,
isDirectory: true
)
}
static func canonicalDeliveryId(_ rawValue: String?) -> String? {
guard let rawValue,
rawValue == rawValue.lowercased(),
let uuid = UUID(uuidString: rawValue),
uuid.uuidString.lowercased() == rawValue else { return nil }
return rawValue
}
func createDelivery() -> String? {
guard ensureRootExists() else { return nil }
for _ in 0..<3 {
let deliveryId = UUID().uuidString.lowercased()
guard let marker = markerURL(
deliveryId: deliveryId,
state: .pending
) else { continue }
do {
try Data().write(to: marker, options: .withoutOverwriting)
guard isDirectRegularFile(marker) else {
_ = removeEntry(marker)
continue
}
return deliveryId
} catch {
continue
}
}
return nil
}
func markerURL(
deliveryId: String,
state: NativePasteDeliveryMarkerState
) -> URL? {
guard let deliveryId = Self.canonicalDeliveryId(deliveryId) else {
return nil
}
return rootURL.appendingPathComponent(
"\(Self.markerPrefix)\(deliveryId).\(state.rawValue)",
isDirectory: false
)
}
func stagingURL(
deliveryId: String,
fileExtension: String
) -> URL? {
guard let deliveryId = Self.canonicalDeliveryId(deliveryId),
Self.allowedExtensions.contains(fileExtension) else { return nil }
let itemId = UUID().uuidString.lowercased()
return rootURL.appendingPathComponent(
"\(deliveryId)-\(itemId)-paste.\(fileExtension)",
isDirectory: false
)
}
func settle(deliveryId: String) -> NativePasteDeliverySettlement {
guard let deliveryId = Self.canonicalDeliveryId(deliveryId),
isDirectDirectory(rootURL),
let pending = markerURL(deliveryId: deliveryId, state: .pending),
let dartOwned = markerURL(deliveryId: deliveryId, state: .dartOwned),
let reclaiming = markerURL(deliveryId: deliveryId, state: .reclaiming)
else { return .preservedUnknown }
// Marker types are read separately, so the opposing atomic rename can
// momentarily produce a mixed observation. Retry a bounded number of
// times; one source rename permanently stabilizes the state.
for _ in 0..<3 {
let pendingState = noFollowEntry(pending)
let dartOwnedState = noFollowEntry(dartOwned)
let reclaimingState = noFollowEntry(reclaiming)
if pendingState == .missing,
dartOwnedState == .regularFile,
reclaimingState == .missing {
_ = removeEntry(dartOwned)
return .dartOwned
}
if pendingState == .missing,
dartOwnedState == .missing,
reclaimingState == .regularFile {
return finishReclaiming(
deliveryId: deliveryId,
marker: reclaiming
)
}
if pendingState == .regularFile,
dartOwnedState == .missing,
reclaimingState == .missing {
_ = beginReclaiming(deliveryId: deliveryId)
continue
}
}
return .preservedUnknown
}
/// Starts native reclamation without deleting the marker. Staging timeout
/// uses this durable intermediate state until its non-cancellable provider
/// callback has drained, so a crash during a late copy remains recoverable.
func beginReclaiming(deliveryId: String) -> Bool {
guard let deliveryId = Self.canonicalDeliveryId(deliveryId),
isDirectDirectory(rootURL),
let pending = markerURL(deliveryId: deliveryId, state: .pending),
let dartOwned = markerURL(deliveryId: deliveryId, state: .dartOwned),
let reclaiming = markerURL(
deliveryId: deliveryId,
state: .reclaiming
) else { return false }
if noFollowEntry(pending) == .missing,
noFollowEntry(dartOwned) == .missing,
noFollowEntry(reclaiming) == .regularFile {
return true
}
guard noFollowEntry(pending) == .regularFile,
noFollowEntry(dartOwned) == .missing,
noFollowEntry(reclaiming) == .missing,
exclusiveRename(from: pending, to: reclaiming) else {
return false
}
return noFollowEntry(pending) == .missing &&
noFollowEntry(dartOwned) == .missing &&
noFollowEntry(reclaiming) == .regularFile
}
/// Reconciles only v2 markers. Legacy, unmarked, Dart-owned, malformed,
/// linked, and nested entries are intentionally invisible to this sweep.
@discardableResult
func reconcileStartup() -> Bool {
if noFollowEntry(rootURL) == .missing { return true }
guard isDirectDirectory(rootURL),
let children = directChildren() else { return false }
var pendingIds = Set<String>()
var reclaimingIds = Set<String>()
for child in children where isDirectRegularFile(child) {
if let id = deliveryId(
fromMarkerName: child.lastPathComponent,
state: .pending
) {
pendingIds.insert(id)
} else if let id = deliveryId(
fromMarkerName: child.lastPathComponent,
state: .reclaiming
) {
reclaimingIds.insert(id)
}
}
for deliveryId in reclaimingIds.sorted() {
guard !hasDartOwnedMarker(deliveryId: deliveryId) else { continue }
_ = settle(deliveryId: deliveryId)
if hasNativeOwnedMarker(deliveryId: deliveryId) {
return false
}
}
for deliveryId in pendingIds.subtracting(reclaimingIds).sorted() {
guard !hasDartOwnedMarker(deliveryId: deliveryId) else { continue }
_ = settle(deliveryId: deliveryId)
if hasNativeOwnedMarker(deliveryId: deliveryId) {
return false
}
}
return true
}
func removeStrictItems(
deliveryId: String,
items: [PlatformNativePasteImageItem]
) {
guard let deliveryId = Self.canonicalDeliveryId(deliveryId),
isDirectDirectory(rootURL) else { return }
for item in items {
let itemURL = URL(fileURLWithPath: item.filePath).standardizedFileURL
guard isStrictItemURL(itemURL, deliveryId: deliveryId),
isDirectRegularFile(itemURL) else { continue }
_ = removeEntry(itemURL)
}
}
func isStrictItemURL(_ itemURL: URL, deliveryId: String) -> Bool {
guard let deliveryId = Self.canonicalDeliveryId(deliveryId),
itemURL.standardizedFileURL.deletingLastPathComponent() == rootURL,
strictItemName(
itemURL.lastPathComponent,
deliveryId: deliveryId
) else { return false }
return true
}
private func ensureRootExists() -> Bool {
do {
try fileManager.createDirectory(
at: rootURL,
withIntermediateDirectories: true
)
return isDirectDirectory(rootURL)
} catch {
return false
}
}
private func hasDartOwnedMarker(deliveryId: String) -> Bool {
guard let marker = markerURL(
deliveryId: deliveryId,
state: .dartOwned
) else { return false }
return isDirectRegularFile(marker)
}
private func hasNativeOwnedMarker(deliveryId: String) -> Bool {
let nativeStates: [NativePasteDeliveryMarkerState] = [
.pending,
.reclaiming,
]
return nativeStates.contains { state in
guard let marker = markerURL(
deliveryId: deliveryId,
state: state
) else { return false }
return isDirectRegularFile(marker)
}
}
private func deliveryId(
fromMarkerName name: String,
state: NativePasteDeliveryMarkerState
) -> String? {
let suffix = ".\(state.rawValue)"
guard name.hasPrefix(Self.markerPrefix), name.hasSuffix(suffix) else {
return nil
}
let start = name.index(name.startIndex, offsetBy: Self.markerPrefix.count)
let end = name.index(name.endIndex, offsetBy: -suffix.count)
guard start < end else { return nil }
return Self.canonicalDeliveryId(String(name[start..<end]))
}
private func strictItemName(_ name: String, deliveryId: String) -> Bool {
let prefix = "\(deliveryId)-"
guard name.hasPrefix(prefix) else { return false }
let remainder = String(name.dropFirst(prefix.count))
for fileExtension in Self.allowedExtensions {
let suffix = "-paste.\(fileExtension)"
guard remainder.hasSuffix(suffix) else { continue }
let itemId = String(remainder.dropLast(suffix.count))
return Self.canonicalDeliveryId(itemId) != nil
}
return false
}
/// Returns true only once no direct regular item owned by this delivery
/// remains. A failed deletion retains `.reclaiming` for the next startup
/// pass instead of turning the item into an unmarked orphan.
private func reclaimStrictDeliveryFiles(deliveryId: String) -> Bool {
guard let children = directChildren() else { return false }
var removedEveryOwnedEntry = true
for child in children {
guard isStrictItemURL(child, deliveryId: deliveryId),
isDirectRegularFile(child) else { continue }
if !removeEntry(child) {
removedEveryOwnedEntry = false
}
}
guard removedEveryOwnedEntry,
let remaining = directChildren() else { return false }
return !remaining.contains { child in
isStrictItemURL(child, deliveryId: deliveryId) &&
isDirectRegularFile(child)
}
}
private func finishReclaiming(
deliveryId: String,
marker: URL
) -> NativePasteDeliverySettlement {
guard reclaimStrictDeliveryFiles(deliveryId: deliveryId),
removeEntry(marker),
noFollowEntry(marker) == .missing else {
return .preservedUnknown
}
return .reclaimed
}
private func directChildren() -> [URL]? {
if let directoryContentsOverride {
return directoryContentsOverride(rootURL)
}
return try? fileManager.contentsOfDirectory(
at: rootURL,
includingPropertiesForKeys: nil,
options: [.skipsSubdirectoryDescendants]
)
}
private func removeEntry(_ url: URL) -> Bool {
if let removeItemOverride {
return removeItemOverride(url)
}
do {
try fileManager.removeItem(at: url)
return true
} catch {
return false
}
}
private func isDirectRegularFile(_ url: URL) -> Bool {
guard url.standardizedFileURL.deletingLastPathComponent() == rootURL
else { return false }
return noFollowEntry(url) == .regularFile
}
private func isDirectDirectory(_ url: URL) -> Bool {
guard url.standardizedFileURL == rootURL else { return false }
return noFollowEntry(url) == .directory
}
/// Returns the filesystem type of the path itself. `FileManager`'s
/// attribute lookup can follow symbolic links, which would let a linked
/// marker or item impersonate an owned regular file during reconciliation.
private func noFollowEntry(_ url: URL) -> NativePasteNoFollowEntry {
var metadata = stat()
let result = url.path.withCString { path in
lstat(path, &metadata)
}
guard result == 0 else {
return errno == ENOENT ? .missing : .inaccessible
}
switch metadata.st_mode & mode_t(S_IFMT) {
case mode_t(S_IFREG):
return .regularFile
case mode_t(S_IFDIR):
return .directory
default:
return .other
}
}
private func exclusiveRename(from source: URL, to destination: URL) -> Bool {
source.path.withCString { sourcePath in
destination.path.withCString { destinationPath in
renameatx_np(
AT_FDCWD,
sourcePath,
AT_FDCWD,
destinationPath,
UInt32(RENAME_EXCL)
) == 0
}
}
}
}
/// Serial startup gate shared by configuration and user-triggered delivery
/// creation. A failed sweep is never memoized, and no new pending marker is
/// admitted until a later retry proves reconciliation completed.
final class NativePasteStartupGate {
private var didReconcile = false
@discardableResult
func reconcileIfNeeded(store: NativePasteDeliveryStore) -> Bool {
if didReconcile { return true }
guard store.reconcileStartup() else { return false }
didReconcile = true
return true
}
func createDeliveryAfterReconciliation(
store: NativePasteDeliveryStore
) -> String? {
guard reconcileIfNeeded(store: store) else { return nil }
return store.createDelivery()
}
}
/// Separates the UI acknowledgement deadline from marker-backed ownership.
final class NativePasteDeliveryOwnership {
private let lock = NSLock()
private let acknowledgement: NativePasteDeliveryCompletion
private let deliveryId: String
private let store: NativePasteDeliveryStore
private let operationQueue: DispatchQueue
private var ownershipResolved = false
init(
deliveryId: String,
store: NativePasteDeliveryStore,
operationQueue: DispatchQueue,
acknowledgement: @escaping (Bool) -> Void
) {
self.deliveryId = deliveryId
self.store = store
self.operationQueue = operationQueue
self.acknowledgement = NativePasteDeliveryCompletion(
completion: acknowledgement
)
}
func acknowledgementTimedOut() {
guard reserveOwnershipResolution() else { return }
settleReservedOwnership(
decision: .indeterminate,
resolveAcknowledgement: true
)
}
@discardableResult
func resolveFromDart(decision: NativePasteDeliveryDecision) -> Bool {
guard reserveOwnershipResolution() else { return false }
settleReservedOwnership(
decision: decision,
resolveAcknowledgement: true
)
return decision == .accepted
}
@discardableResult
private func reserveOwnershipResolution() -> Bool {
lock.lock()
let isFirstOwnershipResolution = !ownershipResolved
if isFirstOwnershipResolution {
ownershipResolved = true
}
lock.unlock()
return isFirstOwnershipResolution
}
private func settleReservedOwnership(
decision: NativePasteDeliveryDecision,
resolveAcknowledgement: Bool
) {
operationQueue.async { [acknowledgement, deliveryId, store] in
let settlement = store.settle(deliveryId: deliveryId)
let consumed: Bool
if decision == .accepted {
consumed = true
} else {
// A failed Dart callback after its claim could not roll the
// marker back. The same filesystem evidence governs an
// indeterminate timeout: successful reclamation permits the
// ordinary Flutter paste path, while Dart-owned or unknown
// marker states remain preservation-only/fail-closed.
consumed = settlement != .reclaimed
}
if resolveAcknowledgement {
acknowledgement.resolve(consumed)
}
}
}
@discardableResult
func resolveFromDart(consumed: Bool) -> Bool {
resolveFromDart(decision: consumed ? .accepted : .rejected)
}
}
/// Coordinates the non-cancellable `NSItemProvider` staging callbacks.
///
/// A provider attempt is not an image slot: only a successfully staged item
/// consumes one. `timeout()` closes the coordinator exactly once, removes
/// everything accumulated so far, and makes every late callback remove its own
/// staged file instead of reviving the operation. Timeout ownership remains
/// durable until the one non-cancellable in-flight callback has drained.
final class NativePasteStagingCoordinator {
typealias Stage = (
_ providerIndex: Int,
_ maxBytes: Int64,
_ completion: @escaping (PlatformNativePasteImageItem?, Int64) -> Void
) -> Void
private let lock = NSLock()
private let providerCount: Int
private let maxItemCount: Int
private let maxItemBytes: Int64
private let maxAggregateBytes: Int64
private let removeItems: ([PlatformNativePasteImageItem]) -> Void
private let onTimeoutStarted: () -> Void
private let onTimeoutDrained: () -> Void
private var completion: (([PlatformNativePasteImageItem], Bool) -> Void)?
private var stage: Stage?
private var nextProviderIndex = 0
private var inFlightProviderIndex: Int?
private var stagedItems: [PlatformNativePasteImageItem] = []
private var stagedBytes: Int64 = 0
private var finished = false
private var timeoutTransitionCompleted = false
private var timeoutDrainDelivered = false
init(
providerCount: Int,
maxItemCount: Int,
maxItemBytes: Int64,
maxAggregateBytes: Int64,
removeItems: @escaping ([PlatformNativePasteImageItem]) -> Void,
onTimeoutStarted: @escaping () -> Void = {},
onTimeoutDrained: @escaping () -> Void = {},
completion: @escaping ([PlatformNativePasteImageItem], Bool) -> Void
) {
self.providerCount = providerCount
self.maxItemCount = maxItemCount
self.maxItemBytes = maxItemBytes
self.maxAggregateBytes = maxAggregateBytes
self.removeItems = removeItems
self.onTimeoutStarted = onTimeoutStarted
self.onTimeoutDrained = onTimeoutDrained
self.completion = completion
}
func start(stage: @escaping Stage) {
lock.lock()
guard !finished, self.stage == nil else {
lock.unlock()
return
}
self.stage = stage
lock.unlock()
advance()
}
func timeout() {
let cleanup: [PlatformNativePasteImageItem]
let callback: (([PlatformNativePasteImageItem], Bool) -> Void)?
lock.lock()
guard !finished else {
lock.unlock()
return
}
finished = true
cleanup = stagedItems
stagedItems.removeAll(keepingCapacity: false)
stage = nil
callback = completion
completion = nil
lock.unlock()
// Persist `.reclaiming` before reporting timeout. If the process exits
// while the provider later creates or copies its file, startup still
// has an exact marker with which to reclaim it.
onTimeoutStarted()
removeItems(cleanup)
callback?([], true)
var shouldDeliverDrain = false
lock.lock()
timeoutTransitionCompleted = true
if inFlightProviderIndex == nil, !timeoutDrainDelivered {
timeoutDrainDelivered = true
shouldDeliverDrain = true
}
lock.unlock()
if shouldDeliverDrain { onTimeoutDrained() }
}
private func advance() {
let request: (index: Int, maxBytes: Int64, stage: Stage)?
let result: [PlatformNativePasteImageItem]?
let callback: (([PlatformNativePasteImageItem], Bool) -> Void)?
lock.lock()
if finished {
request = nil
result = nil
callback = nil
} else if stagedItems.count >= maxItemCount ||
nextProviderIndex >= providerCount ||
stagedBytes >= maxAggregateBytes {
finished = true
result = stagedItems
stagedItems.removeAll(keepingCapacity: false)
request = nil
callback = completion
completion = nil
stage = nil
} else if let stage {
let index = nextProviderIndex
nextProviderIndex += 1
inFlightProviderIndex = index
request = (
index,
min(maxItemBytes, maxAggregateBytes - stagedBytes),
stage
)
result = nil
callback = nil
} else {
request = nil
result = nil
callback = nil
}
lock.unlock()
if let result {
callback?(result, false)
return
}
guard let request else { return }
request.stage(request.index, request.maxBytes) { item, byteCount in
// Retain the coordinator until this non-cancellable provider
// callback arrives so a post-timeout staged file is still removed.
self.didStage(
providerIndex: request.index,
item: item,
byteCount: byteCount
)
}
}
private func didStage(
providerIndex: Int,
item: PlatformNativePasteImageItem?,
byteCount: Int64
) {
var rejectedItems: [PlatformNativePasteImageItem] = []
var shouldAdvance = false
var shouldDeliverDrain = false
lock.lock()
if finished {
if let item { rejectedItems.append(item) }
if inFlightProviderIndex == providerIndex {
inFlightProviderIndex = nil
}
if timeoutTransitionCompleted,
inFlightProviderIndex == nil,
!timeoutDrainDelivered {
timeoutDrainDelivered = true
shouldDeliverDrain = true
}
lock.unlock()
removeItems(rejectedItems)
if shouldDeliverDrain { onTimeoutDrained() }
return
}
guard inFlightProviderIndex == providerIndex else {
if let item { rejectedItems.append(item) }
lock.unlock()
removeItems(rejectedItems)
return
}
inFlightProviderIndex = nil
if let item,
byteCount > 0,
byteCount <= maxItemBytes,
byteCount <= maxAggregateBytes - stagedBytes {
stagedItems.append(item)
stagedBytes += byteCount
} else if let item {
rejectedItems.append(item)
}
shouldAdvance = true
lock.unlock()
removeItems(rejectedItems)
if shouldAdvance { advance() }
}
}
/// Lazy representation loaders for one pasteboard item.
///
/// Clipboard providers are not necessarily file-backed, so callers try data
/// before the file-oriented fallbacks.
struct NativePasteProviderRepresentations {
typealias DataLoader = (
_ completion: @escaping (Data?, Error?) -> Void
) -> Void
typealias FileLoader = (
_ completion: @escaping (URL?, Error?) -> Void
) -> Void
typealias InPlaceFileLoader = (
_ completion: @escaping (URL?, Bool, Error?) -> Void
) -> Void
let loadData: DataLoader
let loadFile: FileLoader
let loadInPlaceFile: InPlaceFileLoader
init(
loadData: @escaping DataLoader,
loadFile: @escaping FileLoader,
loadInPlaceFile: @escaping InPlaceFileLoader
) {
self.loadData = loadData
self.loadFile = loadFile
self.loadInPlaceFile = loadInPlaceFile
}
init(provider: NSItemProvider, typeIdentifier: String) {
loadData = { completion in
provider.loadDataRepresentation(
forTypeIdentifier: typeIdentifier,
completionHandler: completion
)
}
loadFile = { completion in
provider.loadFileRepresentation(
forTypeIdentifier: typeIdentifier,
completionHandler: completion
)
}
loadInPlaceFile = { completion in
provider.loadInPlaceFileRepresentation(
forTypeIdentifier: typeIdentifier,
completionHandler: completion
)
}
}
}
/// Resolves one image provider into app-owned staging.
///
/// Data is the canonical clipboard representation. File and in-place-file
/// loading remain as compatibility fallbacks for document-backed providers.
struct NativePasteProviderStager {
typealias StagedFile = (url: URL, byteCount: Int64)
let stageData: (Data) -> StagedFile?
let stageFile: (URL) -> StagedFile?
let stageInPlaceFile: (URL) -> StagedFile?
func stage(
representations: NativePasteProviderRepresentations,
maxBytes: Int64,
completion: @escaping (URL?, Int64) -> Void
) {
representations.loadData { data, _ in
if let data, !data.isEmpty {
let byteCount = Int64(data.count)
guard byteCount <= maxBytes else {
completion(nil, 0)
return
}
if let staged = stageData(data) {
completion(staged.url, staged.byteCount)
return
}
}
stageFileRepresentation(
representations: representations,
completion: completion
)
}
}
private func stageFileRepresentation(
representations: NativePasteProviderRepresentations,
completion: @escaping (URL?, Int64) -> Void
) {
representations.loadFile { sourceURL, _ in
if let sourceURL, let staged = stageFile(sourceURL) {
completion(staged.url, staged.byteCount)
return
}
representations.loadInPlaceFile { sourceURL, _, _ in
guard let sourceURL,
let staged = stageInPlaceFile(sourceURL) else {
completion(nil, 0)
return
}
completion(staged.url, staged.byteCount)
}
}
}
}
struct NativePasteImageDataItem {
let data: Data
let mimeType: String
let fileExtension: String
}
private enum NativePasteImageSource {
case data(NativePasteImageDataItem)
case provider(NSItemProvider)
}
func nativePasteValidatedImageTypeIdentifier(
for data: Data,
advertisedIdentifier: String
) -> String? {
guard UTType(advertisedIdentifier)?.conforms(to: .image) == true,
let source = CGImageSourceCreateWithData(data as CFData, nil),
CGImageSourceGetStatus(source) == .statusComplete,
let detectedIdentifier = CGImageSourceGetType(source) as String?
else { return nil }
return detectedIdentifier
}
/// Exposes native iOS paste events from Flutter's text input view to Dart.
final class NativePasteBridge: NativePasteHostApi {
static let shared = NativePasteBridge()
private static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "app.cogwheel.conduit",
category: "native-paste"
)
private static let maxImageCount = 4
private static let maxImageBytes: Int64 = 20 * 1024 * 1024
private static let maxAggregateBytes: Int64 = 60 * 1024 * 1024
private static let stagingTimeout: TimeInterval = 5
private static let acknowledgementTimeout: TimeInterval = 5
private static let supportedTypes: [(
type: UTType,
mimeType: String,
fileExtension: String
)] = [
(.gif, "image/gif", "gif"),
(.png, "image/png", "png"),
(.jpeg, "image/jpeg", "jpg"),
(.webP, "image/webp", "webp"),
(.tiff, "image/tiff", "tiff"),
(.heic, "image/heic", "heic"),
(.heif, "image/heif", "heif"),
(.bmp, "image/bmp", "bmp"),
]
private static var didSwizzle = false
private let storageQueue = DispatchQueue(
label: "app.cogwheel.conduit.native-paste-storage",
qos: .utility
)
private let deliveryStore = NativePasteDeliveryStore()
private let startupGate = NativePasteStartupGate()
private var flutterApi: NativePasteFlutterApi?
private init() {}
func configure(messenger: FlutterBinaryMessenger) {
flutterApi = NativePasteFlutterApi(binaryMessenger: messenger)
NativePasteHostApiSetup.setUp(binaryMessenger: messenger, api: self)
storageQueue.async { [weak self] in
guard let self else { return }
_ = startupGate.reconcileIfNeeded(store: deliveryStore)
}
Self.installSwizzlesIfNeeded()
}
func requestPaste(
completion: @escaping (Result<Bool, Error>) -> Void
) {
handlePasteAction { consumed in
completion(.success(consumed))
}
}
private static func installSwizzlesIfNeeded() {
guard !didSwizzle else { return }
didSwizzle = true
// Only swizzle the base class. FlutterSecureTextInputView inherits
// from FlutterTextInputView, so the swizzle applies automatically.
// Swizzling both causes infinite recursion: the subclass swizzle
// sees the parent's already-swizzled Method, making the exchange
// a no-op and leaving conduit_canPerformAction pointing at itself.
guard let targetClass = NSClassFromString("FlutterTextInputView")
else { return }
swizzlePaste(for: targetClass)
swizzleCanPerformAction(for: targetClass)
swizzlePasteConfiguration(for: targetClass)
}
private static func swizzlePaste(for targetClass: AnyClass) {
let originalSelector = #selector(UIResponder.paste(_:))
let swizzledSelector = #selector(
UIResponder.conduit_handlePaste(_:))
guard
let originalMethod = class_getInstanceMethod(
targetClass,
originalSelector
),
let swizzledMethod = class_getInstanceMethod(
UIResponder.self,
swizzledSelector
)
else {
return
}
let didAddMethod = class_addMethod(
targetClass,
swizzledSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod)
)
if didAddMethod,
let newMethod = class_getInstanceMethod(targetClass, swizzledSelector) {
method_exchangeImplementations(originalMethod, newMethod)
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}
private static func swizzleCanPerformAction(for targetClass: AnyClass) {
let originalSelector = #selector(
UIResponder.canPerformAction(_:withSender:))
let swizzledSelector = #selector(
UIResponder.conduit_canPerformAction(_:withSender:))
guard
let originalMethod = class_getInstanceMethod(
targetClass,
originalSelector
),
let swizzledMethod = class_getInstanceMethod(
UIResponder.self,
swizzledSelector
)
else {
return
}
let didAddMethod = class_addMethod(
targetClass,
swizzledSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod)
)
if didAddMethod,
let newMethod = class_getInstanceMethod(targetClass, swizzledSelector) {
method_exchangeImplementations(originalMethod, newMethod)
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}
private static func swizzlePasteConfiguration(for targetClass: AnyClass) {
let originalSelector = #selector(getter: UIResponder.pasteConfiguration)
let swizzledSelector = #selector(
getter: UIResponder.conduit_pasteConfiguration
)
guard
let originalMethod = class_getInstanceMethod(
targetClass,
originalSelector
),
let swizzledMethod = class_getInstanceMethod(
UIResponder.self,
swizzledSelector
)
else {
return
}
let didAddMethod = class_addMethod(
targetClass,
swizzledSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod)
)
if didAddMethod,
let newMethod = class_getInstanceMethod(targetClass, swizzledSelector) {
method_exchangeImplementations(originalMethod, newMethod)
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}
fileprivate func handlePasteAction(
completion: @escaping (Bool) -> Void
) {
let deliveryCompletion = NativePasteDeliveryCompletion(
completion: completion
)
let pasteboard = UIPasteboard.general
let directItems = Self.directImageDataItems(from: pasteboard)
let sources: [NativePasteImageSource]
if directItems.isEmpty {
sources = pasteboard.itemProviders.compactMap { provider in
guard Self.supportedTypes.contains(where: { supportedType in
provider.hasItemConformingToTypeIdentifier(
supportedType.type.identifier
)
}) else { return nil }
return .provider(provider)
}
} else {
sources = directItems.map(NativePasteImageSource.data)
}
Self.logger.debug(
"Paste action found \(directItems.count) direct item(s) and \(sources.count - directItems.count) provider(s)"
)
guard !sources.isEmpty else {
deliveryCompletion.resolve(false)
return
}
storageQueue.async { [weak self] in
guard let self else {
deliveryCompletion.resolve(false)
return
}
guard let deliveryId = startupGate
.createDeliveryAfterReconciliation(store: deliveryStore) else {
deliveryCompletion.resolve(false)
return
}
let store = deliveryStore
let operationQueue = storageQueue
stageImageSources(
sources,
deliveryId: deliveryId
) { [weak self] items, timedOut in
Self.logger.debug(
"Paste staging finished with \(items.count) item(s); timedOut=\(timedOut)"
)
guard !items.isEmpty else {
if timedOut {
// `.reclaiming` stays durable until the coordinator's
// non-cancellable provider callback drains.
deliveryCompletion.resolve(false)
return
}
operationQueue.async {
_ = store.settle(deliveryId: deliveryId)
deliveryCompletion.resolve(false)
}
return
}
let delivery = NativePasteDeliveryOwnership(
deliveryId: deliveryId,
store: store,
operationQueue: operationQueue,
acknowledgement: { consumed in
deliveryCompletion.resolve(consumed)
}
)
let payload = PlatformNativePastePayload(
kind: .images,
items: items,
deliveryId: deliveryId
)
DispatchQueue.main.async { [weak self] in
guard let self, let flutterApi = self.flutterApi else {
delivery.resolveFromDart(decision: .rejected)
return
}
DispatchQueue.main.asyncAfter(
deadline: .now() + Self.acknowledgementTimeout
) {
delivery.acknowledgementTimedOut()
}
flutterApi.onPaste(payload: payload) { result in
let decision = Self.pasteDeliveryDecision(result)
let outcome: String
switch decision {
case .accepted:
outcome = "accepted"
case .rejected:
outcome = "rejected"
case .indeterminate:
outcome = "indeterminate"
}
Self.logger.debug(
"Dart paste acknowledgement: \(outcome, privacy: .public)"
)
delivery.resolveFromDart(decision: decision)
}
}
}
}
}
private static func directImageDataItems(
from pasteboard: UIPasteboard
) -> [NativePasteImageDataItem] {
let itemCount = pasteboard.numberOfItems
guard itemCount > 0,
let advertisedTypes = pasteboard.types(
forItemSet: IndexSet(integersIn: 0..<itemCount)
) else { return [] }
var results: [NativePasteImageDataItem] = []
var aggregateBytes: Int64 = 0
for (itemIndex, identifiers) in advertisedTypes.enumerated() {
if results.count >= maxImageCount { break }
var directItem: NativePasteImageDataItem?
for advertisedIdentifier in identifiers
where UTType(advertisedIdentifier)?.conforms(to: .image) == true {
guard let data = pasteboard.data(
forPasteboardType: advertisedIdentifier,
inItemSet: IndexSet(integer: itemIndex)
)?.first,
!data.isEmpty else { continue }
let byteCount = Int64(data.count)
guard byteCount <= maxImageBytes,
aggregateBytes + byteCount <= maxAggregateBytes,
let supportedType = supportedType(
forImageData: data,
advertisedIdentifier: advertisedIdentifier
) else { continue }
directItem = NativePasteImageDataItem(
data: data,
mimeType: supportedType.mimeType,
fileExtension: supportedType.fileExtension
)
break
}
guard let directItem else { continue }
results.append(directItem)
aggregateBytes += Int64(directItem.data.count)
}
return results
}
private static func supportedType(
forImageData data: Data,
advertisedIdentifier: String
) -> (type: UTType, mimeType: String, fileExtension: String)? {
guard let detectedIdentifier = nativePasteValidatedImageTypeIdentifier(
for: data,
advertisedIdentifier: advertisedIdentifier
) else { return nil }
return supportedTypes.first {
$0.type.identifier == detectedIdentifier
}
}
static func pasteDeliveryDecision<Failure: Error>(
_ result: Result<Bool, Failure>
) -> NativePasteDeliveryDecision {
switch result {
case .success(true):
return .accepted
case .success(false):
return .rejected
case .failure:
return .indeterminate
}
}
@discardableResult
private func stageImageSources(
_ sources: [NativePasteImageSource],
deliveryId: String,
completion: @escaping ([PlatformNativePasteImageItem], Bool) -> Void
) -> NativePasteStagingCoordinator {
let store = deliveryStore
let operationQueue = storageQueue
let coordinator = NativePasteStagingCoordinator(
providerCount: sources.count,
maxItemCount: Self.maxImageCount,
maxItemBytes: Self.maxImageBytes,
maxAggregateBytes: Self.maxAggregateBytes,
removeItems: { items in
store.removeStrictItems(
deliveryId: deliveryId,
items: items
)
},
onTimeoutStarted: {
operationQueue.sync {
_ = store.beginReclaiming(deliveryId: deliveryId)
}
},
onTimeoutDrained: {
operationQueue.async {
_ = store.settle(deliveryId: deliveryId)
}
},
completion: completion
)
// `NSItemProvider` has no reliable cancellation primitive. Start this
// bound before the first provider request; the coordinator owns both
// accumulated and late files if the deadline wins.
DispatchQueue.global(qos: .userInitiated).asyncAfter(
deadline: .now() + Self.stagingTimeout
) {
coordinator.timeout()
}
coordinator.start { [weak self] index, maxBytes, didStage in
guard let self else {
didStage(nil, 0)
return
}
let source = sources[index]
if case .data(let dataItem) = source {
guard let staged = writeDataToStaging(
data: dataItem.data,
fileExtension: dataItem.fileExtension,
deliveryId: deliveryId,
maxBytes: maxBytes
) else {
didStage(nil, 0)
return
}
didStage(PlatformNativePasteImageItem(
mimeType: dataItem.mimeType,
filePath: staged.url.path
), staged.byteCount)
return
}
guard case .provider(let provider) = source else {
didStage(nil, 0)
return
}
guard let supportedType = Self.supportedTypes.first(where: {
provider.hasItemConformingToTypeIdentifier($0.type.identifier)
}) else {
didStage(nil, 0)
return
}
stageProvider(
provider,
type: supportedType.type,
fileExtension: supportedType.fileExtension,
deliveryId: deliveryId,
maxBytes: maxBytes
) { stagedURL, byteCount in
if let stagedURL, byteCount > 0 {
didStage(PlatformNativePasteImageItem(
mimeType: supportedType.mimeType,
filePath: stagedURL.path
), byteCount)
} else {
didStage(nil, 0)
}
}
}
return coordinator
}
private func stageProvider(
_ provider: NSItemProvider,
type: UTType,
fileExtension: String,
deliveryId: String,
maxBytes: Int64,
completion: @escaping (URL?, Int64) -> Void
) {
let typeIdentifier = type.identifier
let stager = NativePasteProviderStager(
stageData: { [weak self] data in
self?.writeDataToStaging(
data: data,
fileExtension: fileExtension,
deliveryId: deliveryId,
maxBytes: maxBytes
)
},
stageFile: { [weak self] sourceURL in
self?.copyToStaging(
sourceURL: sourceURL,
fileExtension: fileExtension,
deliveryId: deliveryId,
maxBytes: maxBytes
)
},
stageInPlaceFile: { [weak self] sourceURL in
guard let self else { return nil }
return withCoordinatedNativePasteRead(
at: sourceURL,
{ coordinatedURL in
self.copyToStaging(
sourceURL: coordinatedURL,
fileExtension: fileExtension,
deliveryId: deliveryId,
maxBytes: maxBytes
)
}
)
}
)
stager.stage(
representations: NativePasteProviderRepresentations(
provider: provider,
typeIdentifier: typeIdentifier
),
maxBytes: maxBytes,
completion: { stagedURL, byteCount in
Self.logger.debug(
"Provider staging result: file=\(stagedURL != nil); bytes=\(byteCount)"
)
completion(stagedURL, byteCount)
}
)
}
private func writeDataToStaging(
data: Data,
fileExtension: String,
deliveryId: String,
maxBytes: Int64
) -> (url: URL, byteCount: Int64)? {
let byteCount = Int64(data.count)
guard byteCount > 0,
byteCount <= maxBytes,
let destination = deliveryStore.stagingURL(
deliveryId: deliveryId,
fileExtension: fileExtension
) else { return nil }
do {
try data.write(to: destination, options: .withoutOverwriting)
return (destination, byteCount)
} catch {
try? FileManager.default.removeItem(at: destination)
return nil
}
}
private func copyToStaging(
sourceURL: URL,
fileExtension: String,
deliveryId: String,
maxBytes: Int64
) -> (url: URL, byteCount: Int64)? {
guard maxBytes > 0,
let destination = deliveryStore.stagingURL(
deliveryId: deliveryId,
fileExtension: fileExtension
) else { return nil }
do {
let input = try FileHandle(forReadingFrom: sourceURL)
defer { try? input.close() }
guard FileManager.default.createFile(
atPath: destination.path,
contents: nil
) else {
throw CocoaError(.fileWriteUnknown)
}
let output = try FileHandle(forWritingTo: destination)
defer { try? output.close() }
var copied: Int64 = 0
while true {
let chunk = try input.read(upToCount: 64 * 1024) ?? Data()
if chunk.isEmpty { break }
copied += Int64(chunk.count)
guard copied <= maxBytes else {
try? FileManager.default.removeItem(at: destination)
return nil
}
try output.write(contentsOf: chunk)
}
guard copied > 0 else {
try? FileManager.default.removeItem(at: destination)
return nil
}
return (destination, copied)
} catch {
try? FileManager.default.removeItem(at: destination)
return nil
}
}
}
extension UIResponder {
@objc func conduit_handlePaste(_ sender: Any?) {
let pasteboardChangeCount = UIPasteboard.general.changeCount
let editorContext = nativePasteEditorContext(for: self)
NativePasteBridge.shared.handlePasteAction { [weak self] consumed in
guard !consumed, let self else { return }
let forwardToFlutter: () -> Void = { [weak self] in
guard let self else { return }
guard nativePasteFallbackContextMatches(
responderIsFirstResponder: self.isFirstResponder,
expectedPasteboardChangeCount: pasteboardChangeCount,
currentPasteboardChangeCount: UIPasteboard.general.changeCount,
expectedEditorContext: editorContext,
currentEditorContext: nativePasteEditorContext(for: self)
) else { return }
self.conduit_handlePaste(sender)
}
if Thread.isMainThread {
forwardToFlutter()
} else {
DispatchQueue.main.async(execute: forwardToFlutter)
}
}
}
@objc func conduit_canPerformAction(
_ action: Selector,
withSender sender: Any?
) -> Bool {
if action == #selector(UIResponder.paste(_:)), isFirstResponder {
return true
}
return conduit_canPerformAction(action, withSender: sender)
}
@objc var conduit_pasteConfiguration: UIPasteConfiguration? {
get {
UIPasteConfiguration(acceptableTypeIdentifiers: [
UTType.image.identifier,
UTType.png.identifier,
UTType.jpeg.identifier,
UTType.gif.identifier,
UTType.webP.identifier,
UTType.tiff.identifier,
UTType.heic.identifier,
UTType.heif.identifier,
UTType.bmp.identifier,
UTType.text.identifier,
UTType.plainText.identifier,
])
}
set {
// Ignore setter; the swizzled getter defines accepted types.
}
}
}