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.
- Exhaustive
switchoverNFCError: add the new cases or adefault. The same applies toActiveAuthenticationStatus(.verifiedUnauthenticatedKey) andDESFireFileType(.transactionMAC). - Cancel detection: replace checks for
sessionInvalidatedor message text witherror.isNFCCancellation. - Ultralight / NTAG page writes: lock, OTP and configuration pages need
allowIrreversible: true; pages above 15 need amemoryMap. Ultralight CrequiresAuthenticationForReadswas inverted and is now correct. - Card identification: SAK
20hwith a MIFARE Plus ATQA is.smartMX; NTAG I2C, Ultralight Nano and NTAG 223 DNA are.mifareUltralight. - ISO 15693:
lock:becomespermanentlyLock:;dsfid,afiandicReferenceare optional. - NDEF: parsing is strict, absolute-URI records take the URI from TYPE, and an erased tag throws
ndefMessageNotFoundon every family. - Transit:
TransitTransaction.amountandbalanceAfterareInt?; Japan IC and CEPAS records are classified differently. - FeliCa:
FeliCaCommands.readPlainServicesnow throws;writeFeliCaBlockswrites one block per command unless you passmaxBlocksPerWrite:. - DESFire:
pcdCapabilitiesdefaults to empty and takes 0-6 bytes; AuthenticateISO reads return 8 bytes less. - Passport: pass
trustAnchorsDER, or expectfallbackwhere you used to seesucceeded; EF.SOD and AA signatures are checked more strictly; long TD1 / TD2 document numbers are reassembled. - PACE: Brainpool chips now run PACE instead of falling back to BAC; PACEDomainParameterInfo entries moved out of
paceInfos. - Logging:
Logger.appand the other app-template statics are gone;debugFile/infoFile/errorFilearenfcDebugFile/nfcInfoFile/nfcErrorFile. - 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:
- Reads skip the lock and reserved bytes declared by Lock Control / Memory Control TLVs (T2T 1.0 §2.3.4 / §2.3.5).
- Writes keep NULL and control TLVs at the start of the data area, stop before declared reserved bytes and may read pages 7-10 first. A preserved control TLV costs its bytes of capacity, so
ndefCapacityExceededcan be reported for a message that fitted before. A tag whose reserved bytes share a page with the control TLVs is refused withunsupportedOperation. - A message that fills the data area exactly is written without a Terminator TLV, and
ndefCapacityExceeded(required:capacity:)no longer counts the terminator.
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):
- Japan IC ticket-machine and fare-adjustment records (console types
03,07,08,12-15) have anentryStationonly;exitStationisnil. - Japan IC bus records (console type
05) have no stations. The line and stop codes are inmetadata["busLineCode"]andmetadata["busStopCode"]. - Japan IC history slots whose date field is zero (unused slots such as
00 00 00 80 ...) are dropped, sotransactionscan be shorter than 20; records with an out-of-range date are kept withdate == nil. Process07h(new issue, initial deposit) is a.topup. - CEPAS type
76(bus refund) is a.topupand04(service charge) a.purchase, both with anamount. They were.unknownwithout one.
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:
ActiveAuthStatus,PassiveAuthStatusandActiveAuthPublicKeyare deprecated typealiases forActiveAuthenticationStatus,PassiveAuthenticationStatusandActiveAuthenticationPublicKey.readPassportreturns a partial model withdataGroupReadStatuswhen some data groups cannot be read, and throws when DG1 was requested but not read. Secure-messaging and transport errors propagate instead of being skipped.- For a transport you already hold, call
readPassport(transport:mrzKey:dataGroups:performActiveAuth:trustAnchorsDER:onProgress:). It neither starts nor ends a session. - To rebuild a model from stored bytes, use
PassportModel(rawDataGroups:cardAccessRaw:performPassiveAuthentication:trustAnchorsDER:dataGroupReadStatus:)instead of parsing DG1 yourself. - Stored data: the raw values of
SecurityProtocol.taECDSASHA224and.taECDSASHA256were swapped in 1.x. Data encoded by 1.x decodes to the other case for these two OIDs. Re-parse DG14 or EF.CardAccess bytes instead of relying on saved enum values.
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:
- EF.SOD's eContentType is not
2.23.136.1.1.1(SODParser.ldsSecurityObjectOID), the signed contentType attribute does not match it, the LDSSecurityObject version is not 0 or 1, or there is not exactly one SignerInfo. - The DSC has a keyUsage extension without digitalSignature (
trustChainInvalid). - The LDSSecurityObject names a hash algorithm the library does not know (
unsupportedHashAlgorithm; 1.1.2 used the SignedData digest algorithm and reported a hash mismatch). - A data group that was read is missing from EF.SOD. An empty result (no data group compared) is not reported as
succeeded.
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.
PACEHandler.performPACEthrowsunsupportedOperationfor an unknown protocol OID (wassecureMessagingError) and checks every parameter before sending anything. UsePACEHandler.selectPACEInfo(from:)orisSupported(_:)to choose.SecurityInfos.paceInfosno longer contains PACEDomainParameterInfo entries; they are inpaceDomainParameterInfos.SecureMessagingTransporttakes an AES SSC of 16 bytes or 8 bytes (zero-extended); any other length makes every exchange throwinvalidParameter.
// 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.2 | 2.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:
- Session and transports:
NFCSessionManager,MiFareTransport,ISO7816Transport,FeliCaTransport,ISO15693Transport,SecureMessagingTransport,LogStore. - Readers and commands (structs in 1.1.2):
UltralightCommands,UltralightDump,DESFireCommands,DESFireDump,FeliCaCommands,FeliCaType3Reader,ISO15693Reader,ISO15693SecurityManager,Type4Reader,PassportReader,MyNumberCardReader,JapanICReader,OctopusReader,KSX6924Reader,TUnionReader,CEPASReader.
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:
| Hook | Called |
|---|---|
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:
SecureMessagingTransport: the session keys, the send sequence counter,protect/unprotectand the MAC and IV helpers are private, andsendAPDUispublicbut notopen. It owns the exchange gate and the SSC; an override could desynchronize the counter or leak keys.send,identifier,isSessionEndedandsessionEndedReasonare available.NFCSessionManager: the scan state and its continuation are private, and the scan, invalidate, alert-message and delegate methods are not overridable, so every continuation is resumed exactly once.LockedValue,AsyncExchangeGate,SessionTerminationMonitor:final, with private lock state.OpenSSLPACECurve:final, with only aData-in /Data-out API. It is intentionally notSendable: it owns an OpenSSLBN_CTXand is used within a single PACE run.- OpenSSL pointer helpers in
ActiveAuthenticationVerifierandSODParser, andLogStore's internal queue helper, stay private.