# Core NFC Patterns

Longer examples for the `core-nfc` skill. Each snippet assumes the session is
already running and the tag is connected, as shown in the parent SKILL.md. Tag
calls use their `async` forms from inside a `Task`, which avoids the Swift 6
warnings about non-`Sendable` sessions and tags captured in `@Sendable`
completion handlers. A delegate callback that starts such a `Task` passes the
session and tag in as `nonisolated(unsafe)` copies; a plain capture fails the
region-isolation check.

## Contents

- [Handling several tags at once](#handling-several-tags-at-once)
- [ISO 7816 APDU commands](#iso-7816-apdu-commands)
- [ISO 15693 tags](#iso-15693-tags)
- [MIFARE tags](#mifare-tags)
- [FeliCa tags](#felica-tags)
- [Messages with several records](#messages-with-several-records)
- [Locking a tag](#locking-a-tag)
- [A SwiftUI scanner](#a-swiftui-scanner)
- [Error codes](#error-codes)

## Handling several tags at once

`tagReaderSession(_:didDetect:)` and `readerSession(_:didDetect:)` hand over an
array. More than one entry means several tags are in the field together, and
commands sent to one of them may be answered by the wrong chip or not at all.

```swift
func tagReaderSession(_ reader: NFCTagReaderSession, didDetect found: [NFCTag]) {
    nonisolated(unsafe) let reader = reader
    if found.count > 1 {
        reader.alertMessage = "Several tags are in range. Keep only one near the phone."
        Task {
            try? await Task.sleep(for: .milliseconds(500))
            reader.restartPolling()
        }
        return
    }
    if !found.isEmpty {
        nonisolated(unsafe) let tags = found
        Task {
            try? await reader.connect(to: tags[0])
            // Continue with the single tag.
        }
    } else {
        reader.restartPolling()
    }
}
```

The session stays alive while polling restarts, so the user does not see the
sheet close and reopen. Scanning a series of tags one after another follows the
same idea: after finishing with a tag, update `alertMessage` and call
`restartPolling()` instead of invalidating.

## ISO 7816 APDU commands

An APDU is built from class, instruction, two parameters, data and the expected
response length. Selecting an application by its identifier:

```swift
func select(_ card: any NFCISO7816Tag, in session: NFCTagReaderSession) async {
    let aid = Data([0xF0, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06])
    let selectApp = NFCISO7816APDU(
        instructionClass: 0x00,
        instructionCode: 0xA4,
        p1Parameter: 0x04,
        p2Parameter: 0x00,
        data: aid,
        expectedResponseLength: -1
    )
    do {
        let (_, sw1, sw2) = try await card.sendCommand(apdu: selectApp)
        guard (sw1, sw2) == (0x90, 0x00) else {
            session.invalidate(errorMessage: "The card rejected the application.")
            return
        }
        let readFile = NFCISO7816APDU(
            instructionClass: 0x00,
            instructionCode: 0xB0,
            p1Parameter: 0x00,
            p2Parameter: 0x00,
            data: Data(),
            expectedResponseLength: 256
        )
        let (body, status1, status2) = try await card.sendCommand(apdu: readFile)
        let ok = status1 == 0x90 && status2 == 0x00
        session.alertMessage = ok ? "Read \(body.count) bytes." : "Read failed."
        session.invalidate()
    } catch {
        session.invalidate(errorMessage: "The card stopped answering.")
    }
}
```

`90 00` is the success status word. An `expectedResponseLength` of `-1` lets the
card decide how much to return. The AID used here must also appear in the
Info.plist select-identifiers list.

| Command | CLA | INS |
|---------|-----|-----|
| SELECT | 0x00 | 0xA4 |
| READ BINARY | 0x00 | 0xB0 |
| UPDATE BINARY | 0x00 | 0xD6 |
| READ RECORD | 0x00 | 0xB2 |
| GET DATA | 0x00 | 0xCA |

A small helper used by the examples below:

```swift
func hex(_ bytes: Data) -> String {
    bytes.map { String(format: "%02X", $0) }.joined()
}
```

## ISO 15693 tags

```swift
func readBlocks(_ label: any NFCISO15693Tag, in session: NFCTagReaderSession) async {
    do {
        let info = try await label.systemInfo(requestFlags: [.highDataRate])
        let uidHex = hex(info.uniqueIdentifier)
        session.alertMessage = "Tag \(uidHex): \(info.totalBlocks) blocks of \(info.blockSize) bytes"

        let first = try await label.readSingleBlock(
            requestFlags: [.address, .highDataRate],
            blockNumber: 0
        )
        let rest = try await label.readMultipleBlocks(
            requestFlags: [.address, .highDataRate],
            blockRange: NSRange(location: 1, length: 4)
        )
        session.alertMessage = "Read \(1 + rest.count) blocks, \(first.count) bytes in block 0."
        session.invalidate()
    } catch {
        session.invalidate(errorMessage: "No system info.")
    }
}
```

`systemInfo(requestFlags:)` returns an `NFCISO15693SystemInfo`: the
identifier, DSFID, AFI, block size, total block count and IC reference, which
tells you how far you can read. The callback form, `getSystemInfo`, hands the
same value over in a `Result`.

## MIFARE tags

```swift
func inspect(_ chip: any NFCMiFareTag, in session: NFCTagReaderSession) async {
    switch chip.mifareFamily {
    case .ultralight:
        let pages = try? await chip.sendMiFareCommand(commandPacket: Data([0x30, 0x04]))
        session.alertMessage = pages == nil ? "Read failed." : "Pages 4 to 7 read."
        session.invalidate()
    case .desfire:
        // DESFire needs the application-specific command set of your card issuer.
        session.invalidate(errorMessage: "DESFire cards need a dedicated command set.")
    case .plus, .unknown:
        session.invalidate(errorMessage: "This MIFARE type is not handled.")
    @unknown default:
        session.invalidate(errorMessage: "Unrecognised MIFARE family.")
    }
}
```

The Ultralight READ command `0x30` with page `0x04` returns four pages
starting at page 4.

## FeliCa tags

FeliCa cards are only found when the session polls `.iso18092` and the
Info.plist lists the exact system codes; wildcards are rejected.

```swift
func readSystemCodes(_ card: any NFCFeliCaTag, in session: NFCTagReaderSession) async {
    let codes = (try? await card.requestSystemCode()) ?? []
    session.alertMessage = "System codes: \(codes.map(hex).joined(separator: ", "))"
    session.invalidate()
}
```

## Messages with several records

One NDEF message can mix record kinds, for example a label, a link and an app
payload:

```swift
func shelfMessage(for id: String) -> NFCNDEFMessage? {
    guard
        let label = NFCNDEFPayload.wellKnownTypeTextPayload(string: "Shelf \(id)", locale: Locale(identifier: "en")),
        let address = URL(string: "https://example.com/shelf/" + id),
        let link = NFCNDEFPayload.wellKnownTypeURIPayload(url: address)
    else { return nil }
    let data = NFCNDEFPayload(
        format: .nfcExternal,
        type: Data("example.com:shelf".utf8),
        identifier: Data(),
        payload: Data(#"{"id":"\#(id)"}"#.utf8)
    )
    return NFCNDEFMessage(records: [label, link, data])
}
```

### Checking capacity

`queryNDEFStatus` returns the capacity in bytes. Compare it with the message
length before writing:

```swift
let (status, capacity) = try await tag.queryNDEFStatus()
guard status == .readWrite else {
    session.invalidate(errorMessage: "Writes are blocked on this tag.")
    return
}
guard message.length <= capacity else {
    session.invalidate(errorMessage: "Data needs \(message.length) bytes; the tag holds \(capacity).")
    return
}
try await tag.writeNDEF(message)
session.invalidate()
```

## Locking a tag

`writeLock` turns a tag read-only for good. There is no undo, so confirm the
status is `.readWrite`, and confirm with the user, before calling it.

```swift
do {
    try await tag.writeLock()
    session.alertMessage = "Tag locked."
    session.invalidate()
} catch {
    session.invalidate(errorMessage: "Could not lock: \(error.localizedDescription)")
}
```

## A SwiftUI scanner

```swift
import CoreNFC
import SwiftUI

@Observable @MainActor
final class StickerScanner: NSObject {
    var lastText: String?
    var lastLink: URL?
    var isScanning = false
    var failure: String?
    let isAvailable = NFCNDEFReaderSession.readingAvailable

    private var session: NFCNDEFReaderSession?

    func start() {
        guard isAvailable else { return }
        session = NFCNDEFReaderSession(
            delegate: self,
            queue: nil,
            invalidateAfterFirstRead: true
        )
        session?.alertMessage = "Hold the sticker near your iPhone."
        session?.begin()
        isScanning = true
    }
}

extension StickerScanner: NFCNDEFReaderSessionDelegate {
    nonisolated func readerSessionDidBecomeActive(_ reader: NFCNDEFReaderSession) {}

    nonisolated func readerSession(_ reader: NFCNDEFReaderSession, didDetectNDEFs found: [NFCNDEFMessage]) {
        let record = found.first?.records.first
        let link = record?.wellKnownTypeURIPayload()
        let text = record?.wellKnownTypeTextPayload().0
        Task { @MainActor in
            self.lastLink = link
            self.lastText = text
        }
    }

    nonisolated func readerSession(_ reader: NFCNDEFReaderSession, didInvalidateWithError error: any Error) {
        let code = (error as? NFCReaderError)?.code
        let quiet = code == .readerSessionInvalidationErrorUserCanceled
            || code == .readerSessionInvalidationErrorFirstNDEFTagRead
        let message = quiet ? nil : error.localizedDescription
        Task { @MainActor in
            self.failure = message
            self.isScanning = false
            self.session = nil
        }
    }
}

struct StickerScanView: View {
    @State private var scanner = StickerScanner()

    var body: some View {
        if scanner.isAvailable {
            VStack(spacing: 12) {
                Button("Scan Sticker") { scanner.start() }
                    .buttonStyle(.borderedProminent)
                    .disabled(scanner.isScanning)
                if let link = scanner.lastLink { Link(link.absoluteString, destination: link) }
                if let text = scanner.lastText { Text(text) }
                if let failure = scanner.failure { Text(failure).foregroundStyle(.red) }
            }
        } else {
            ContentUnavailableView(
                "NFC Unavailable",
                systemImage: "wave.3.right.circle.fill",
                description: Text("This device cannot read NFC tags.")
            )
        }
    }
}
```

The delegate callbacks arrive off the main actor, so they are `nonisolated`
and hop back with `Task { @MainActor in }` before touching state. Only plain
values cross that hop.

## Error codes

| `NFCReaderError.Code` | Meaning |
|-----------------------|---------|
| `.readerSessionInvalidationErrorUserCanceled` | User pressed Cancel |
| `.readerSessionInvalidationErrorFirstNDEFTagRead` | Session ended after one read because `invalidateAfterFirstRead` was true |
| `.readerSessionInvalidationErrorSessionTimeout` | The 60-second session limit ran out |
| `.readerSessionInvalidationErrorSessionTerminatedUnexpectedly` | The system ended the session |
| `.readerSessionInvalidationErrorSystemIsBusy` | Another NFC session is already running |
| `.readerTransceiveErrorTagConnectionLost` | The tag left the field mid-exchange |
| `.readerTransceiveErrorRetryExceeded` | Too many failed attempts |
| `.readerTransceiveErrorTagNotConnected` | A command was sent before connecting |

A recovery helper that turns codes into user text:

```swift
func userMessage(for error: Error) -> String? {
    guard let code = (error as? NFCReaderError)?.code else { return error.localizedDescription }
    switch code {
    case .readerSessionInvalidationErrorUserCanceled,
         .readerSessionInvalidationErrorFirstNDEFTagRead:
        return nil
    case .readerSessionInvalidationErrorSessionTimeout:
        return "Scanning took too long. Try again."
    case .readerTransceiveErrorTagConnectionLost:
        return "Keep the tag still until the scan finishes."
    default:
        return error.localizedDescription
    }
}
```

Whatever the message, clear the stored session in the same callback.
