Migrating to 2.0

What to change when moving from 1.1.2 to 2.0.0. The Swift 6.2 toolchain, the iOS 15 deployment target and the async/await API are unchanged. The full list is in CHANGELOG.md.

What You Must Change

Items 1-12 can stop your code from compiling or change what it does at run time. Item 13 only produces deprecation warnings.

  1. Exhaustive switch over NFCError: add the new cases or a default. The same applies to ActiveAuthenticationStatus (.verifiedUnauthenticatedKey) and DESFireFileType (.transactionMAC).
  2. Cancel detection: replace checks for sessionInvalidated or message text with error.isNFCCancellation.
  3. Ultralight / NTAG page writes: lock, OTP and configuration pages need allowIrreversible: true; pages above 15 need a memoryMap. Ultralight C requiresAuthenticationForReads was inverted and is now correct.
  4. Card identification: SAK 20h with a MIFARE Plus ATQA is .smartMX; NTAG I2C, Ultralight Nano and NTAG 223 DNA are .mifareUltralight.
  5. ISO 15693: lock: becomes permanentlyLock:; dsfid, afi and icReference are optional.
  6. NDEF: parsing is strict, absolute-URI records take the URI from TYPE, and an erased tag throws ndefMessageNotFound on every family.
  7. Transit: TransitTransaction.amount and balanceAfter are Int?; Japan IC and CEPAS records are classified differently.
  8. FeliCa: FeliCaCommands.readPlainServices now throws; writeFeliCaBlocks writes one block per command unless you pass maxBlocksPerWrite:.
  9. DESFire: pcdCapabilities defaults to empty and takes 0-6 bytes; AuthenticateISO reads return 8 bytes less.
  10. Passport: pass trustAnchorsDER, or expect fallback where you used to see succeeded; EF.SOD and AA signatures are checked more strictly; long TD1 / TD2 document numbers are reassembled.
  11. PACE: Brainpool chips now run PACE instead of falling back to BAC; PACEDomainParameterInfo entries moved out of paceInfos.
  12. Logging: Logger.app and the other app-template statics are gone; debugFile / infoFile / errorFile are nfcDebugFile / nfcInfoFile / nfcErrorFile.
  13. Renamed facade functions and types: follow the fix-its; the old names forward to the new ones.

Exhaustive NFCError Switches

NFCError gains userCanceled, systemBusy, sessionInvalidatedByCaller, scanInProgress, checksumMismatch, responseChainTooLong(maxFrames:), applicationNotFound(aid:), iso15693Error(code:), pinVerificationFailed(remainingAttempts:), pinRequired(_:), irreversibleWriteRefused(page:), tagNotNDEFFormatted, ndefMessageNotFound, ndefCapacityExceeded(required:capacity:), invalidParameter(_:) and malformedDump(_:). A switch that listed every 1.1.2 case no longer compiles.

Before (1.1.2):

switch error {
case .nfcNotAvailable: return "NFC is not available"
case .tagConnectionLost: return "Hold the tag still"
// ... one line for every 1.1.2 case, no default
}

After:

switch error {
case .nfcNotAvailable: return "NFC is not available"
case .tagConnectionLost: return "Hold the tag still"
case .tagNotNDEFFormatted: return "Format the tag first"
case let .ndefCapacityExceeded(required, capacity): return "Needs \(required) bytes; the tag holds \(capacity)"
default: return error.localizedDescription   // NFCError is a LocalizedError
}

Some conditions changed case: the FeliCa attribute-block checksum is checksumMismatch (was crcMismatch), ISO 15693 codes 0x12 / 0x14 and Type 4 6982 / 6985 are tagLocked, and an unrecognized transit card is tagNotSupported. Messages you matched as text in unsupportedOperation or invalidResponse now have typed cases. See Errors.

Cancellation

When the user dismisses the NFC sheet, 2.0 throws NFCError.userCanceled instead of sessionInvalidated(String). Cancelling the task throws CancellationError, and ending a pending scan with invalidate() throws sessionInvalidatedByCaller. isNFCCancellation covers all three.

Before:

do {
    let (info, dump) = try await CoreExtendedNFC.scanAndDump()
} catch NFCError.sessionInvalidated(let reason) where reason.contains("cancel") {
    return
} catch {
    showAlert(error)
}

After:

do {
    let (info, dump) = try await CoreExtendedNFC.scanAndDump()
} catch where error.isNFCCancellation {
    return   // dismissed, task cancelled, or ended by the app: stay silent
} catch {
    showAlert(error)
}

A second concurrent scan on the same NFCSessionManager now throws NFCError.scanInProgress.

Ultralight / NTAG Writes

Page writes refuse anything that may be irreversible and throw NFCError.irreversibleWriteRefused(page:) before sending. This covers pages 0-2, page 3 (OTP) when the write would change a bit, and pages outside the user area when you pass a memory map. Without a memory map, the top-level functions also refuse every page above 15. WRITE now requires the 0Ah ACK.

Before:

try await CoreExtendedNFC.writePage(20, data: payload, transport: transport)
try await CoreExtendedNFC.writePage(41, data: config, transport: transport)   // CFG page

After:

let map = CoreExtendedNFC.ultralightMemoryMap(for: info.type)

// User memory of a larger tag: pass the memory map.
try await CoreExtendedNFC.writeUltralightPage(20, data: payload, memoryMap: map, transport: transport)

// Lock, OTP, CFG, PWD/PACK or key pages: explicit opt-in. This can brick the tag.
try await CoreExtendedNFC.writeUltralightPage(
    41,
    data: config,
    memoryMap: map,
    allowIrreversible: true,
    transport: transport
)

UltralightCommands.writePage(_:data:memoryMap:allowIrreversible:) and compatibilityWrite take the same parameters. UltralightDump.restore(dump:map:target:) validates every page, and the card type when target is given, before it writes anything. See Card commands.

Ultralight C access configuration

UltralightCAccessConfiguration.requiresAuthenticationForReads was inverted in 1.1.2. It is now true when AUTH1 bit 0 is 0 (reads and writes protected) and false when it is 1 (writes only), per MF0ICU2 §7.5.8. An AUTH0 of 30h or higher means no protection, so firstProtectedPage is nil. Remove any negation you added to work around the old value.

let access = try await CoreExtendedNFC.readUltralightCAccessConfiguration(transport: transport)
// Before: let readsProtected = !access.requiresAuthenticationForReads   // worked around the inversion
// After:
let readsProtected = access.requiresAuthenticationForReads
let protected = access.firstProtectedPage != nil   // nil for any AUTH0 >= 30h

Card Identification

CardIdentifier.identify / CoreExtendedNFC.identifyCard(atqa:sak:) no longer return .mifarePlusSL3_2K / .mifarePlusSL3_4K for SAK 20h. AN10833 §2.1 says MIFARE Plus SL3 is told apart by its ATS, so SAK 20h with a MIFARE Plus ATQA (0002 / 0004 / 0042 / 0044) is .smartMX, like SAK 28h / 38h. ATQA 0304 + SAK 20h is still DESFire. A tag that CoreNFC itself reports as the MIFARE Plus family is still .mifarePlusSL3_2K.

GET_VERSION identification also checks the major version: NTAG I2C (product subtype 05h), Ultralight Nano and NTAG 223 DNA are .mifareUltralight instead of .ntag216, MF0UL11 or .ntag213, so they no longer get a memory map that does not match the chip.

Before:

switch CoreExtendedNFC.identifyCard(atqa: atqa, sak: 0x20) {
case .mifarePlusSL3_2K, .mifarePlusSL3_4K: showMIFAREPlus()
default: break
}

After:

switch CoreExtendedNFC.identifyCard(atqa: atqa, sak: 0x20) {
case .mifarePlusSL3_2K, .mifarePlusSL3_4K: showMIFAREPlus()   // no longer returned for SAK 20h
case .smartMX: probeWithISO7816(transport)                      // check the ATS or SELECT an application
default: break
}

// A generic .mifareUltralight (NTAG I2C, Nano, 223 DNA) has only the generic map.
let map = CoreExtendedNFC.ultralightMemoryMap(for: info.type)

ISO 15693

Locking AFI or DSFID is irreversible, so it now needs permanentlyLock: true. The lock: overloads are deprecated, and their lock: argument has no default, so configureAFI(x) always calls the variant that does not lock. Facade block ranges take ClosedRange<Int> and are checked against 0...255.

Before:

try await CoreExtendedNFC.configureISO15693AFI(0x01, lock: true, transport: transport)
let blocks = try await CoreExtendedNFC.readISO15693Blocks(
    range: NSRange(location: 0, length: 4),
    transport: transport
)
let system = try await CoreExtendedNFC.getISO15693SystemInfo(transport: transport)
let afi: UInt8 = system.afi
let dump = try await reader.readAllBlocks(transport: iso15693Transport)

After:

try await CoreExtendedNFC.configureISO15693AFI(0x01, permanentlyLock: true, transport: transport)
let blocks = try await CoreExtendedNFC.readISO15693Blocks(range: 0...3, transport: transport)
let system = try await CoreExtendedNFC.getISO15693SystemInfo(transport: transport)
if let afi = system.afi {        // dsfid, afi and icReference are UInt8?
    show(afi)
}
let dump = try await ISO15693Reader(transport: iso15693Transport).readAllBlocks(info: info)

Tags that do not report their memory size are rejected by dump and NDEF write. Tags with more than 256 blocks are handled with the extended commands, and a dump stops at block 255 (marked partiallyReadable) when the tag rejects them.

NDEF

NDEF parsing follows NDEF 1.0 §3.2.6 and Android's NdefMessage. These used to be accepted and now throw invalidResponse: bytes after the record that carries ME, a TNF Empty record with a type, ID or payload, and a TNF Unknown or Unchanged record with a type. The reserved TNF 07h is read like Unknown. validatedData(), and so writeNDEF, refuses the same records and any .unchanged / .reserved record with invalidParameter.

Absolute-URI records (TNF 03h) decode the URI from the TYPE field, not the payload. Build them with the URI in type. A Smart Poster without a U record takes its URI from an absolute-URI child.

Before:

let record = NDEFRecord(
    typeNameFormat: .absoluteURI,
    type: Data(),
    payload: Data("https://example.com".utf8)
)

After:

let record = NDEFRecord(
    typeNameFormat: .absoluteURI,
    type: Data("https://example.com".utf8),   // parsedPayload is .uri("https://example.com")
    payload: Data()
)

Reading an erased tag (empty NDEF message) throws NFCError.ndefMessageNotFound on Type 3, Type 4 and Type 5 too, as Type 2 already did. In 1.1.2 those families threw a parse error.

do {
    let message = try await CoreExtendedNFC.readNDEF(info: info, transport: transport)
    show(message)
} catch NFCError.ndefMessageNotFound {
    showEmptyTag()   // erased or never written, on every family
}

Type 2 tags:

Type 5 tags whose CC requires the special frame format (CC byte 3 b4) are refused with unsupportedOperation; NDEFTagMapping.type5CCLength(firstBlock:) throws for them too.

If you call the NDEFTagMapping helpers directly: buildType2Area(message:capacity:) gains preservedPrefix: and endsDataArea: (with defaults, so call sites compile; get both from type2WriteLayout(leadingBytes:dataAreaOffset:capacity:) to keep control TLVs), and extractType2TLVNDEF(from:dataAreaOffset:) honours control TLVs. The plans from buildType2Area and type5NDEFWrites are in block order; write them in lengthLastWriteOrder(_:blockSize:tlvOffset:) so an interrupted write leaves an empty message, not a truncated one.

let layout = try NDEFTagMapping.type2WriteLayout(
    leadingBytes: dataAreaBytes,   // bytes read from page 4 on
    dataAreaOffset: 16,
    capacity: capacity
)
let area = try NDEFTagMapping.buildType2Area(
    message: message,
    capacity: layout?.capacity ?? capacity,
    preservedPrefix: layout?.preservedPrefix ?? Data(),
    endsDataArea: layout?.endsDataArea ?? true
)

Transit Amounts

TransitTransaction.amount and balanceAfter are Int?. amount is an unsigned magnitude, so take the direction from type. Do not show nil as zero.

Before:

let spent = balance.transactions.map(\.amount).reduce(0, +)

After:

let spent = balance.transactions.reduce(0) { total, transaction in
    guard let amount = transaction.amount else { return total }
    switch transaction.type {
    case .trip, .purchase: return total + amount
    case .topup, .unknown: return total
    }
}

Tag loss and cancellation during a transit read now throw instead of returning a zero balance or truncated history.

Record classification changed in a few places (Metrodroid SuicaTrip / CEPASTransaction):

for transaction in balance.transactions {
    if let line = transaction.metadata["busLineCode"], let stop = transaction.metadata["busStopCode"] {
        showBus(line: line, stop: stop)        // Japan IC bus record: no stations
    } else if let exit = transaction.exitStation {
        showTrip(from: transaction.entryStation, to: exit)
    } else {
        showStation(transaction.entryStation)  // ticket machine, fare adjustment, purchase
    }
}

FeliCa

FeliCaCommands.readPlainServices is async throws (was async), so a lost tag is reported instead of returning an empty result.

// Before
let snapshots = await commands.readPlainServices(probes)
// After
let snapshots = try await commands.readPlainServices(probes)

writeFeliCaBlocks sends one block per Write Without Encryption by default (was 4), because FeliCa Lite and Lite-S accept only one. Pass maxBlocksPerWrite: for a card known to accept more, such as one whose Type 3 attribute block announces a larger Nbw.

// Before: four blocks per command, rejected by FeliCa Lite / Lite-S
try await CoreExtendedNFC.writeFeliCaBlocks(startingAt: 1, blocks: blocks, transport: transport)
// After: one block per command by default
try await CoreExtendedNFC.writeFeliCaBlocks(startingAt: 1, blocks: blocks, transport: transport)
// Keep the 1.1.2 batching for a card that accepts it
try await CoreExtendedNFC.writeFeliCaBlocks(
    startingAt: 1,
    blocks: blocks,
    maxBlocksPerWrite: 4,
    transport: transport
)

DESFire

pcdCapabilities on authenticateEV2First, the EV2 read helpers and their facades (authenticateDESFireEV2, readDESFireDataAuthenticatedEV2, readDESFireRecordsAuthenticatedEV2, getDESFireValueAuthenticatedEV2) defaults to empty instead of six zero bytes. 1.1.2 required exactly six bytes but never sent them: Part 1 was always KeyNo ‖ 00. The empty default sends the same KeyNo ‖ 00 (LenCap = 0), so callers that kept the default see no wire change. The parameter now takes 0 to 6 bytes (more throws invalidParameter), and a non-empty value is actually sent as LenCap ‖ PCDcap2 (AN12343 §10.1). Drop an explicit six-zero-byte argument: it used to be ignored, and in 2.0 it sends LenCap = 06 and six bytes. The EV2First Part 2 answer must be exactly 32 bytes.

The AuthenticateISO read helpers (readDataAuthenticatedISO, readRecordsAuthenticatedISO, getValueAuthenticatedISO and the readDESFire...AuthenticatedISO / getDESFireValueAuthenticatedISO facades) strip the 8-byte CMAC the card appends, so they return 8 bytes less than in 1.1.2. The MAC is not verified.

Before:

let session = try await CoreExtendedNFC.authenticateDESFireEV2(keyNo: 0, key: key, transport: transport)
// Part 1 on the wire: KeyNo ‖ 00 (the six default capability bytes were never sent)

let (_, raw) = try await CoreExtendedNFC.readDESFireDataAuthenticatedISO(
    fileID: 1, keyNo: 0, key: key, transport: transport
)
let content = raw.dropLast(8)   // removed the CMAC by hand

After:

// Unchanged call, unchanged wire format: Part 1 is still KeyNo ‖ 00 (LenCap = 0).
let session = try await CoreExtendedNFC.authenticateDESFireEV2(keyNo: 0, key: key, transport: transport)
// Pass pcdCapabilities only to send real PCDcap2 bytes (sent as LenCap ‖ PCDcap2).

let (_, content) = try await CoreExtendedNFC.readDESFireDataAuthenticatedISO(
    fileID: 1, keyNo: 0, key: key, transport: transport
)   // CMAC already removed

Hardware versions 0x12 / 0x22 / 0x42 are EV2 and 0x33 / 0x43 are EV3.

Passport / eMRTD

Trust is judged strictly. The Passive Authentication stage is succeeded only when the Document Signer certificate (DSC) has a validated certification path to a CSCA in trustAnchorsDER (PassiveAuthenticationStatus.fullyVerified). Active Authentication is succeeded only when, in addition, DG15 matches its hash in EF.SOD. Without trust anchors both stages are fallback at best, and a valid AA signature over an unauthenticated key has status .verifiedUnauthenticatedKey.

Before:

let passport = try await CoreExtendedNFC.readPassport(mrzKey: mrzKey)
let genuine = passport.securityReport.activeAuthentication.status == .succeeded
let status: ActiveAuthStatus? = passport.activeAuthResult?.status

After:

let passport = try await CoreExtendedNFC.readPassport(
    mrzKey: mrzKey,
    trustAnchorsDER: cscaCertificates   // DER CSCA certificates from your trust list
)
let report = passport.securityReport
let genuine = report.passiveAuthentication.status == .succeeded
    && report.activeAuthentication.status == .succeeded
let status: ActiveAuthenticationStatus? = passport.activeAuthResult?.status

Other changes:

let model = PassportModel(
    rawDataGroups: savedDataGroups,     // [DataGroupId: Data], TLV-wrapped
    trustAnchorsDER: cscaCertificates
)
print(model.mrz?.documentNumber ?? "-", model.securityReport.passiveAuthentication.status)

Stricter Passive and Active Authentication

Some chips that reached succeeded or fallback in 1.1.2 now fail. The Passive Authentication stage is failed when:

A valid signature without a validated certification path is fallback. Active Authentication is failed for RSA moduli under 1024 bits (ActiveAuthenticationVerifier.minimumRSAModulusBits), RSA signatures that are not exactly the modulus length, ECDSA signatures whose r / s are not exactly the curve order length, and a DG15 hash mismatch. Revocation (CRLs) is not checked, and CSCA link certificates must be supplied in trustAnchorsDER with their CSCA.

let report = passport.securityReport
switch report.passiveAuthentication.status {
case .succeeded: showVerified()
case .fallback: showUnverified(report.passiveAuthentication)   // e.g. no trust anchors
case .failed: showRejected(passport.passiveAuthResult?.status) // e.g. .trustChainInvalid
default: showNotChecked()
}

Long document numbers

MRZData reassembles TD1 / TD2 document numbers longer than nine characters (ICAO 9303-5 / -6 §4.2.2, Note j). documentNumber includes the overflow from the optional data field, and optionalData1 no longer starts with the overflow and its check digit. Drop any code that rebuilt the number from optionalData1.

// TD1 line 1: I<UTOD23145890<7349<<<<<<<<<<<
// Before: documentNumber "D23145890", optionalData1 "7349"
// After:  documentNumber "D23145890734", optionalData1 ""
let number = mrz.documentNumber

PACE

PACE runs on the Brainpool curves brainpoolP224r1 to brainpoolP512r1 (parameter IDs 11, 13, 14, 16, 17) as well as NIST P-256 / P-384 / P-521. Chips that only offer Brainpool parameters used to fall back to BAC and now authenticate with PACE; the secure-messaging session and progress messages change accordingly. The first supported PACEInfo in EF.CardAccess is used instead of always the first. Chips that offer only the 192-bit curves, secp224r1, Integrated Mapping or DH still fall back to BAC before any PACE APDU.

// Before
guard let paceInfo = securityInfos.paceInfos.first else { return try await performBAC() }
// After
guard let paceInfo = PACEHandler.selectPACEInfo(from: securityInfos) else {
    return try await performBAC()   // no PACEInfo the handler supports
}
let smTransport = try await PACEHandler.performPACE(
    paceInfo: paceInfo,
    mrzKey: mrzKey,
    transport: transport
)

Renamed Facade Functions

Top-level functions follow verb + family + noun. The old names still compile with a deprecation warning and a fix-it.

1.1.22.0
readPages(startPage:transport:)readUltralightPages(startPage:transport:)
writePage(_:data:transport:)writeUltralightPage(_:data:memoryMap:allowIrreversible:transport:)
fastRead(from:to:transport:)fastReadUltralightPages(from:to:transport:)
compatibilityWrite(_:data:transport:)compatibilityWriteUltralightPage(_:data:memoryMap:allowIrreversible:transport:)
readSignature(transport:)readNTAGSignature(transport:)
readCounter(counterID:transport:)readNTAGCounter(counterID:transport:)
passwordAuth(password:transport:)authenticateUltralightPassword(password:transport:)
readAuthConfig(configStartPage:transport:)readUltralightAuthenticationConfiguration(configStartPage:transport:)
getApplicationIDs(transport:)getDESFireApplicationIDs(transport:)
selectApplication(_:transport:)selectDESFireApplication(_:transport:)
getFileIDs(transport:)getDESFireFileIDs(transport:)
getFileSettings(_:transport:)getDESFireFileSettings(_:transport:)
iso15693CustomCommand(code:parameters:transport:)sendISO15693CustomCommand(code:parameters:transport:)
iso15693Challenge(cryptoSuiteIdentifier:message:transport:)challengeISO15693(cryptoSuiteIdentifier:message:transport:)
iso15693Authenticate(cryptoSuiteIdentifier:message:transport:)authenticateISO15693(cryptoSuiteIdentifier:message:transport:)
iso15693KeyUpdate(keyIdentifier:message:transport:)updateISO15693Key(keyIdentifier:message:transport:)
readISO15693Blocks(range: NSRange, transport:)readISO15693Blocks(range: ClosedRange<Int>, transport:)
getISO15693BlockSecurityStatus(range: NSRange, transport:)getISO15693BlockSecurityStatus(range: ClosedRange<Int>, transport:)
configureISO15693AFI(_:lock:transport:)configureISO15693AFI(_:permanentlyLock:transport:)
configureISO15693DSFID(_:lock:transport:)configureISO15693DSFID(_:permanentlyLock:transport:)
readType4CC(transport:)readType4CapabilityContainer(transport:)
readMyNumberCard(info:transport:items:cardInfoInputSupportPIN:)readMyNumberCard(transport:items:cardInfoInputSupportPIN:onProgress:)
lookupMyNumberRemainingPINAttempts(info:transport:pinType:)lookupMyNumberRemainingPINAttempts(transport:pinType:)

Lower-level renames: ISO15693SecurityManager.configureAFI(_:lock:) / configureDSFID(_:lock:) → permanentlyLock:; ISO15693Reader.readAllBlocks(transport:) → readAllBlocks(info:); ISO15693Reader.writeBlock(_:data:transport:) → writeBlock(at:data:); Type4Tag → Type4Reader (readCC() is readCapabilityContainer()). Methods on UltralightCommands and DESFireCommands keep their names.

Logging

The library no longer declares Logger.app, .ui, .network, .model, .database, .syncEngine, .chatService or Logger.loggingSubsystem. These were app-template leftovers that took common names in your app's Logger namespace. The file-logging helpers are renamed, with no forwarders.

Before:

Logger.app.debugFile("Scanned \(info.type.description)")
Logger.network.errorFile("Upload failed")

After:

import OSLog

extension Logger {
    static let app = Logger(subsystem: Bundle.main.bundleIdentifier ?? "App", category: "App")
}

Logger.app.nfcDebugFile("Scanned \(info.type.description)")
Logger.app.nfcErrorFile("Upload failed", category: "Network")

Library logging goes through NFCLogConfiguration.logger. Payload bytes, UIDs and block contents are hidden unless you set NFCLogConfiguration.logPayloads = true.

Subclassing and Extension

2.0 opens the library for subclassing. These types are open class:

The readers keep their initializers and only hold immutable state, so existing call sites compile; they are now reference types. The main I/O entry points are open and can be overridden. Methods declared in extensions, such as parsers, are public but cannot be overridden (a Swift rule). Value models such as CardInfo, MemoryDump, PassportModel and TransitBalance stay structs.

Thread-safety contract

Every open class is @unchecked Sendable, so the compiler does not check what a subclass adds. Subclasses must preserve thread safety: any mutable state you add must be lock-protected or immutable. LockedValue is public for this purpose; keep its critical sections short and never await inside them.

NFCSessionManager hooks

The session bookkeeping (scan(for:message:), invalidate(), setAlertMessage(_:) and the CoreNFC delegate callbacks) is public but not overridable. Override these hooks instead:

HookCalled
sessionDidBecomeActive(_:)After the session became active.
sessionDidInvalidate(_:error:)After a session ended and any pending scan was failed, with the mapped error.
invalidateAfterFailure(_:)When a top-level flow fails; ends the session and returns the error to rethrow.
class func failureOutcome(for:userCanceled:)Chooses the error to rethrow and the sheet message after a failure.
class func invalidationError(from:)Maps the CoreNFC error that ended a session to NFCError.
class func pollingOptions(from:)Turns PollingTarget values into CoreNFC polling options.
class func identifyTag(_:monitor:)Wraps a connected tag in a transport and describes it, on the connecting task.
import CoreNFC
import CoreExtendedNFC

final class AppSessionManager: NFCSessionManager, @unchecked Sendable {
    private let events = LockedValue<[String]>([])   // added state is lock-protected

    override func sessionDidBecomeActive(_ session: NFCTagReaderSession) {
        events.withLock { $0.append("active") }
    }

    override func sessionDidInvalidate(_ session: NFCTagReaderSession, error: any Error) {
        events.withLock { $0.append("ended: \(error.localizedDescription)") }
    }

    // Hand out a tracing transport for ISO 7816 tags.
    override class func identifyTag(
        _ tag: NFCTag,
        monitor: SessionTerminationMonitor?
    ) throws -> (CardInfo, any NFCTagTransport) {
        let (info, transport) = try super.identifyTag(tag, monitor: monitor)
        guard case let .iso7816(iso7816Tag) = tag else { return (info, transport) }
        return (info, TracingISO7816Transport(tag: iso7816Tag, terminationMonitor: monitor))
    }
}

final class TracingISO7816Transport: ISO7816Transport, @unchecked Sendable {
    override func sendAPDU(_ apdu: CommandAPDU) async throws -> ResponseAPDU {
        let response = try await super.sendAPDU(apdu)
        NFCLog.debug("INS \(apdu.ins) -> SW \(response.statusWord)", source: "Trace")
        return response
    }
}

Injecting a manager

scan, scanAndDump, readPassport and readMyNumberCard take a manager: argument (default NFCSessionManager()). scan returns that manager unchanged; the other three invalidate it before returning.

let (info, transport, manager) = try await CoreExtendedNFC.scan(
    pollingTargets: [.iso14443],
    manager: AppSessionManager()
)
defer { manager.invalidate() }

The facade functions construct the stock readers. To use a reader subclass, create it yourself over a transport from scan:

final class AuditedPassportReader: PassportReader, @unchecked Sendable {
    override func readDataGroup(_ dgId: DataGroupId, transport: SecureMessagingTransport) async throws -> Data {
        let data = try await super.readDataGroup(dgId, transport: transport)
        NFCLog.info("\(dgId.name): \(data.count) bytes", source: "App")
        return data
    }
}

let passport = try await AuditedPassportReader(transport: transport)
    .readPassport(mrzKey: mrzKey, trustAnchorsDER: cscaCertificates)

To customize My Number reads, override MyNumberCardReader.read(items:cardInfoInputSupportPIN:onProgress:). The two-argument read(items:cardInfoInputSupportPIN:) is not overridable and forwards to it.

What Stays Sealed

Some members stay private or non-overridable because overriding them would break session state or security: