NFC: Entitlement TAG statt NDEF, Tag-Session statt NDEF-Session

Der Upload nach App Store Connect scheiterte an der Entitlement-Pruefung:
"NDEF is disallowed" und "TAG is missing in the entitlement". Apple hat den Wert
NDEF zugunsten von TAG abgeloest; mit dem SDK von iOS 26/27 wird er abgelehnt.

TAG ist der Wert fuer NFCTagReaderSession, deshalb laeuft der Lese- und
Schreibweg jetzt darueber statt ueber NFCNDEFReaderSession. Die NDEF-Befehle
bleiben dabei vollstaendig erhalten: alle vier Tag-Protokolle (MiFare, ISO7816,
ISO15693, FeliCa) erben laut SDK-Header von NFCNDEFTag, also gibt es weiterhin
queryNDEFStatus, readNDEF, writeNDEF und writeLock. Gepollt wird iso14443 (die
ueblichen NTAG-Aufkleber) und iso15693 (Waeschetags mancher Hersteller).

Die eigene Klasse hiess NFCTag und kollidierte damit mit dem gleichnamigen
CoreNFC-Protokoll, das der neue Weg braucht - sie heisst jetzt NFCDienst.

Info.plist enthaelt ausserdem die Neuformatierung durch Xcode (Schluessel
sortiert, Kommentare entfernt). Inhaltlich unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 13:48:40 +02:00
co-authored by Claude Opus 5
parent 09a763a823
commit 140b81798c
6 changed files with 147 additions and 150 deletions
+86 -118
View File
@@ -2,123 +2,91 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- Von Xcode aus den Build-Einstellungen gefuellt --> <key>BGTaskSchedulerPermittedIdentifiers</key>
<!-- Deutsch als Sprache der App: Sonst zeichnet iOS DatePicker und <array>
Monatsnamen auf Englisch ("10. Dec 2027"). --> <string>com.scarriffle.vorrania.refresh</string>
<key>CFBundleDevelopmentRegion</key> </array>
<string>de</string> <key>CFBundleDevelopmentRegion</key>
<key>CFBundleLocalizations</key> <string>de</string>
<array> <key>CFBundleDisplayName</key>
<string>de</string> <string>Vorrania</string>
</array> <key>CFBundleExecutable</key>
<key>CFBundleExecutable</key> <string>$(EXECUTABLE_NAME)</string>
<string>$(EXECUTABLE_NAME)</string> <key>CFBundleIdentifier</key>
<key>CFBundleIdentifier</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key>
<key>CFBundleInfoDictionaryVersion</key> <string>6.0</string>
<string>6.0</string> <key>CFBundleLocalizations</key>
<key>CFBundleName</key> <array>
<string>$(PRODUCT_NAME)</string> <string>de</string>
<key>CFBundlePackageType</key> </array>
<string>APPL</string> <key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key> <key>CFBundlePackageType</key>
<string>Vorrania</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>1.0</string>
<key>CFBundleVersion</key> <key>CFBundleURLTypes</key>
<string>1</string> <array>
<dict>
<!-- App-Store-Kategorie. Muss hier stehen: Das Projekt nutzt ein eigenes <key>CFBundleURLName</key>
Info.plist (GENERATE_INFOPLIST_FILE = NO), deshalb wird die <string>com.scarriffle.vorrania</string>
INFOPLIST_KEY_LSApplicationCategoryType-Build-Einstellung NICHT in das <key>CFBundleURLSchemes</key>
gebaute Info.plist uebernommen. --> <array>
<key>LSApplicationCategoryType</key> <string>vorrania</string>
<string>public.app-category.utilities</string> </array>
</dict>
<!-- Export-Compliance: Die App nutzt nur die Standard-Verschluesselung des </array>
Systems (HTTPS ueber Apples Betriebssystem) und faellt damit unter die <key>CFBundleVersion</key>
Ausnahme. Mit diesem Schluessel entfaellt die "App Encryption <string>1</string>
Documentation"-Rueckfrage bei jedem Upload nach App Store Connect. --> <key>ITSAppUsesNonExemptEncryption</key>
<key>ITSAppUsesNonExemptEncryption</key> <false/>
<false/> <key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>UILaunchScreen</key> <key>NFCReaderUsageDescription</key>
<dict/> <string>NFC wird zum Lesen und Beschreiben der Tags an Einzelstücken verwendet.</string>
<key>NSAppTransportSecurity</key>
<!-- Am iPhone bewusst nur Hochkant - damit sich beim Drehen nichts <dict>
verstellt. Am iPad verlangt das System alle Ausrichtungen, solange die <key>NSAllowsLocalNetworking</key>
App nicht auf Vollbild besteht. --> <true/>
<key>UISupportedInterfaceOrientations</key> </dict>
<array> <key>NSCameraUsageDescription</key>
<string>UIInterfaceOrientationPortrait</string> <string>Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet.</string>
</array> <key>UIApplicationShortcutItems</key>
<key>UISupportedInterfaceOrientations~ipad</key> <array>
<array> <dict>
<string>UIInterfaceOrientationPortrait</string> <key>UIApplicationShortcutItemIconType</key>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIApplicationShortcutIconTypeAdd</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <key>UIApplicationShortcutItemTitle</key>
<string>UIInterfaceOrientationLandscapeRight</string> <string>Einlagern</string>
</array> <key>UIApplicationShortcutItemType</key>
<string>com.scarriffle.vorrania.checkin</string>
<!-- Kamera für den Barcode-Scan --> </dict>
<key>NSCameraUsageDescription</key> <dict>
<string>Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet.</string> <key>UIApplicationShortcutItemIconType</key>
<string>UIApplicationShortcutIconTypeRemove</string>
<!-- NFC für die eingenähten Tags an Einzelstücken --> <key>UIApplicationShortcutItemTitle</key>
<key>NFCReaderUsageDescription</key> <string>Auslagern</string>
<string>NFC wird zum Lesen und Beschreiben der Tags an Einzelstücken verwendet.</string> <key>UIApplicationShortcutItemType</key>
<string>com.scarriffle.vorrania.checkout</string>
<!-- Lokaler Server ohne HTTPS (Heimnetz) --> </dict>
<key>NSAppTransportSecurity</key> </array>
<dict> <key>UIBackgroundModes</key>
<key>NSAllowsLocalNetworking</key> <array>
<true/> <string>fetch</string>
</dict> </array>
<key>UILaunchScreen</key>
<!-- Hintergrund-Aktualisierung: die App frischt gelegentlich den Plan der <dict/>
Ablauf-Benachrichtigungen auf. Zeitpunkt bestimmt iOS. --> <key>UISupportedInterfaceOrientations</key>
<key>UIBackgroundModes</key> <array>
<array> <string>UIInterfaceOrientationPortrait</string>
<string>fetch</string> </array>
</array> <key>UISupportedInterfaceOrientations~ipad</key>
<key>BGTaskSchedulerPermittedIdentifiers</key> <array>
<array> <string>UIInterfaceOrientationPortrait</string>
<string>com.scarriffle.vorrania.refresh</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
</array> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<!-- Schnellaktionen beim langen Druck auf das App-Symbol --> </array>
<key>UIApplicationShortcutItems</key>
<array>
<dict>
<key>UIApplicationShortcutItemType</key>
<string>com.scarriffle.vorrania.checkin</string>
<key>UIApplicationShortcutItemTitle</key>
<string>Einlagern</string>
<key>UIApplicationShortcutItemIconType</key>
<string>UIApplicationShortcutIconTypeAdd</string>
</dict>
<dict>
<key>UIApplicationShortcutItemType</key>
<string>com.scarriffle.vorrania.checkout</string>
<key>UIApplicationShortcutItemTitle</key>
<string>Auslagern</string>
<key>UIApplicationShortcutItemIconType</key>
<string>UIApplicationShortcutIconTypeRemove</string>
</dict>
</array>
<!-- vorrania://checkin bzw. vorrania://checkout -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.scarriffle.vorrania</string>
<key>CFBundleURLSchemes</key>
<array>
<string>vorrania</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>
+1 -1
View File
@@ -214,7 +214,7 @@ struct ItemEditView: View {
LabeledContent("UID", value: item.uid) LabeledContent("UID", value: item.uid)
// Für Kleidung: ein eingenähtes NFC-Tag hält, was ein aufgeklebter // Für Kleidung: ein eingenähtes NFC-Tag hält, was ein aufgeklebter
// QR nicht übersteht. Auf dem iPad ausgeblendet – dort gibt es kein NFC. // QR nicht übersteht. Auf dem iPad ausgeblendet – dort gibt es kein NFC.
if NFCTag.verfuegbar { if NFCDienst.verfuegbar {
Button { nfcShown = true } label: { Button { nfcShown = true } label: {
Label("NFC-Tag beschreiben", systemImage: "wave.3.right") Label("NFC-Tag beschreiben", systemImage: "wave.3.right")
} }
+49 -24
View File
@@ -50,17 +50,25 @@ func einzelstueckCode(aus roh: String, nackteUidErlauben: Bool = false) -> Strin
/// aussen sieht das hier wie ein normales `await` aus, damit die Views genauso /// aussen sieht das hier wie ein normales `await` aus, damit die Views genauso
/// aussehen wie beim Kamera-Scan. /// aussehen wie beim Kamera-Scan.
/// ///
/// **Warum `NFCTagReaderSession` und nicht `NFCNDEFReaderSession`:** Seit dem
/// SDK von iOS 26/27 lehnt die App-Store-Prüfung den Entitlement-Wert `NDEF` ab
/// („NDEF is disallowed") und verlangt `TAG`. `TAG` ist der Wert für diese
/// Sitzungsart. Die NDEF-Befehle bleiben trotzdem verfügbar: alle vier
/// Tag-Protokolle (MiFare, ISO7816, ISO15693, FeliCa) erben von `NFCNDEFTag`,
/// also gibt es weiterhin `queryNDEFStatus`, `readNDEF`, `writeNDEF` und
/// `writeLock`.
///
/// Gelesen wird nur mit geöffneter App. iOS startet Apps beim Antippen im /// Gelesen wird nur mit geöffneter App. iOS startet Apps beim Antippen im
/// Hintergrund ausschliesslich über https-Universal-Links auf einer beanspruchten /// Hintergrund ausschliesslich über https-Universal-Links auf einer beanspruchten
/// Domain – eigene Schemes ignoriert es dabei stillschweigend. Eine feste Domain /// Domain – eigene Schemes ignoriert es dabei stillschweigend. Eine feste Domain
/// wäre für einen selbst gehosteten Dienst aber gerade der falsche Weg (siehe /// wäre für einen selbst gehosteten Dienst aber gerade der falsche Weg (siehe
/// Kommentar an `Router.handle(url:)`). /// Kommentar an `Router.handle(url:)`).
final class NFCTag: NSObject { final class NFCDienst: NSObject {
/// Ob das Gerät NFC lesen kann. Auf dem iPad immer `false` – die App läuft /// Ob das Gerät NFC lesen kann. Auf dem iPad immer `false` – die App läuft
/// dort ebenfalls (`TARGETED_DEVICE_FAMILY "1,2"`), deshalb muss jede /// dort ebenfalls (`TARGETED_DEVICE_FAMILY "1,2"`), deshalb muss jede
/// NFC-Bedienung daran hängen. /// NFC-Bedienung daran hängen.
static var verfuegbar: Bool { NFCNDEFReaderSession.readingAvailable } static var verfuegbar: Bool { NFCTagReaderSession.readingAvailable }
enum Fehler: LocalizedError { enum Fehler: LocalizedError {
case nichtVerfuegbar case nichtVerfuegbar
@@ -97,38 +105,42 @@ final class NFCTag: NSObject {
} }
private let queue = DispatchQueue(label: "com.scarriffle.vorrania.nfc") private let queue = DispatchQueue(label: "com.scarriffle.vorrania.nfc")
private var session: NFCNDEFReaderSession? private var session: NFCTagReaderSession?
private var auftrag: Auftrag = .lesen private var auftrag: Auftrag = .lesen
private var weiter: CheckedContinuation<String, Error>? private var weiter: CheckedContinuation<String, Error>?
/// Die Sitzung hält sich selbst am Leben, solange sie läuft – sonst gibt der /// Die Sitzung hält sich selbst am Leben, solange sie läuft – sonst gibt der
/// Aufrufer die Instanz frei und das Delegate meldet sich nie. /// Aufrufer die Instanz frei und das Delegate meldet sich nie.
private var festhalten: NFCTag? private var festhalten: NFCDienst?
// MARK: Öffentlich // MARK: Öffentlich
/// Liest ein Tag und gibt seinen Inhalt roh zurück. /// Liest ein Tag und gibt seinen Inhalt roh zurück.
static func lesen() async throws -> String { static func lesen() async throws -> String {
try await NFCTag().starten(.lesen, hinweis: "Halte das Tag an die Oberseite des iPhones.") try await NFCDienst().starten(.lesen)
} }
/// Beschreibt ein Tag. `sperren` macht es **unwiderruflich** schreibgeschützt. /// Beschreibt ein Tag. `sperren` macht es **unwiderruflich** schreibgeschützt.
static func schreiben(_ nutzlast: String, sperren: Bool = false) async throws { static func schreiben(_ nutzlast: String, sperren: Bool = false) async throws {
_ = try await NFCTag().starten( _ = try await NFCDienst().starten(.schreiben(nutzlast: nutzlast, sperren: sperren))
.schreiben(nutzlast: nutzlast, sperren: sperren),
hinweis: "Halte das Tag an die Oberseite des iPhones."
)
} }
// MARK: Ablauf // MARK: Ablauf
private func starten(_ auftrag: Auftrag, hinweis: String) async throws -> String { private func starten(_ auftrag: Auftrag) async throws -> String {
guard Self.verfuegbar else { throw Fehler.nichtVerfuegbar } guard Self.verfuegbar else { throw Fehler.nichtVerfuegbar }
self.auftrag = auftrag self.auftrag = auftrag
return try await withCheckedThrowingContinuation { fortsetzung in return try await withCheckedThrowingContinuation { fortsetzung in
// iso14443 deckt die üblichen NTAG-/MIFARE-Aufkleber ab, iso15693 die
// Wäschetags, die manche Hersteller stattdessen verbauen.
guard let s = NFCTagReaderSession(
pollingOption: [.iso14443, .iso15693], delegate: self, queue: queue
) else {
fortsetzung.resume(throwing: Fehler.nichtVerfuegbar)
return
}
weiter = fortsetzung weiter = fortsetzung
festhalten = self festhalten = self
let s = NFCNDEFReaderSession(delegate: self, queue: queue, invalidateAfterFirstRead: false) s.alertMessage = "Halte das Tag an die Oberseite des iPhones."
s.alertMessage = hinweis
session = s session = s
s.begin() s.begin()
} }
@@ -230,38 +242,51 @@ final class NFCTag: NSObject {
if let text = record.wellKnownTypeTextPayload().0 { return text } if let text = record.wellKnownTypeTextPayload().0 { return text }
return String(data: record.payload, encoding: .utf8) return String(data: record.payload, encoding: .utf8)
} }
/// Alle vier Tag-Arten erben von `NFCNDEFTag` – die NDEF-Befehle gibt es
/// also unabhängig davon, welche Technik das Tag verwendet.
private static func ndef(aus tag: NFCTag) -> NFCNDEFTag? {
switch tag {
case let .miFare(t): return t
case let .iso7816(t): return t
case let .iso15693(t): return t
case let .feliCa(t): return t
@unknown default: return nil
}
}
} }
// MARK: - NFCNDEFReaderSessionDelegate // MARK: - NFCTagReaderSessionDelegate
extension NFCTag: NFCNDEFReaderSessionDelegate { extension NFCDienst: NFCTagReaderSessionDelegate {
/// Wird nicht aufgerufen, solange `didDetect tags:` vorhanden ist – Core NFC func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {}
/// nimmt dann den Weg über die Tags. Das Protokoll verlangt die Methode
/// trotzdem.
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {}
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
guard tags.count == 1, let tag = tags.first else { guard tags.count == 1, let ersteser = tags.first else {
session.alertMessage = "Mehrere Tags erkannt. Bitte nur eines anhalten." session.alertMessage = "Mehrere Tags erkannt. Bitte nur eines anhalten."
session.restartPolling() session.restartPolling()
return return
} }
session.connect(to: tag) { fehler in guard let ndef = Self.ndef(aus: ersteser) else {
fertig(.failure(Fehler.nichtBeschreibbar))
return
}
session.connect(to: ersteser) { fehler in
if let fehler { if let fehler {
self.fertig(.failure(Fehler.system(fehler.localizedDescription))) self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
return return
} }
switch self.auftrag { switch self.auftrag {
case .lesen: case .lesen:
self.lesen(tag) self.lesen(ndef)
case let .schreiben(nutzlast, sperren): case let .schreiben(nutzlast, sperren):
self.schreiben(tag, nutzlast: nutzlast, sperren: sperren) self.schreiben(ndef, nutzlast: nutzlast, sperren: sperren)
} }
} }
} }
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
// Hat der Vorgang schon ein Ergebnis geliefert, ist das hier nur noch das // Hat der Vorgang schon ein Ergebnis geliefert, ist das hier nur noch das
// Aufräumen danach. // Aufräumen danach.
guard weiter != nil else { return } guard weiter != nil else { return }
+5 -5
View File
@@ -71,9 +71,9 @@ struct NFCSchreibenView: View {
erfolg = false erfolg = false
defer { laeuft = false } defer { laeuft = false }
do { do {
try await NFCTag.schreiben(nutzlast, sperren: sperren) try await NFCDienst.schreiben(nutzlast, sperren: sperren)
erfolg = true erfolg = true
} catch NFCTag.Fehler.abgebrochen { } catch NFCDienst.Fehler.abgebrochen {
// Kein Fehler, sondern eine Entscheidung – nichts anzeigen. // Kein Fehler, sondern eine Entscheidung – nichts anzeigen.
} catch { } catch {
fehler = error.localizedDescription fehler = error.localizedDescription
@@ -93,7 +93,7 @@ struct NFCLesenButton: View {
@State private var laeuft = false @State private var laeuft = false
var body: some View { var body: some View {
if NFCTag.verfuegbar { if NFCDienst.verfuegbar {
Button { Button {
Task { await lesen() } Task { await lesen() }
} label: { } label: {
@@ -107,7 +107,7 @@ struct NFCLesenButton: View {
laeuft = true laeuft = true
defer { laeuft = false } defer { laeuft = false }
do { do {
let roh = try await NFCTag.lesen() let roh = try await NFCDienst.lesen()
// Ein selbst beschriebenes Tag darf auch die nackte UID tragen – anders // Ein selbst beschriebenes Tag darf auch die nackte UID tragen – anders
// als bei der Kamera droht hier keine Verwechslung mit einem Barcode. // als bei der Kamera droht hier keine Verwechslung mit einem Barcode.
guard let uid = einzelstueckCode(aus: roh, nackteUidErlauben: true) else { guard let uid = einzelstueckCode(aus: roh, nackteUidErlauben: true) else {
@@ -115,7 +115,7 @@ struct NFCLesenButton: View {
return return
} }
onUid(uid) onUid(uid)
} catch NFCTag.Fehler.abgebrochen { } catch NFCDienst.Fehler.abgebrochen {
// Nutzer hat abgebrochen – keine Meldung. // Nutzer hat abgebrochen – keine Meldung.
} catch { } catch {
onFehler(error.localizedDescription) onFehler(error.localizedDescription)
+1 -1
View File
@@ -4,7 +4,7 @@
<dict> <dict>
<key>com.apple.developer.nfc.readersession.formats</key> <key>com.apple.developer.nfc.readersession.formats</key>
<array> <array>
<string>NDEF</string> <string>TAG</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+5 -1
View File
@@ -63,5 +63,9 @@ targets:
entitlements: entitlements:
path: Sources/Vorrania.entitlements path: Sources/Vorrania.entitlements
properties: properties:
# "TAG", nicht "NDEF": Die App-Store-Pruefung lehnt NDEF seit dem
# SDK von iOS 26/27 ab ("NDEF is disallowed") und verlangt TAG. Deshalb
# laeuft der Code ueber NFCTagReaderSession; die NDEF-Befehle gibt es
# dort weiterhin, weil alle Tag-Protokolle von NFCNDEFTag erben.
com.apple.developer.nfc.readersession.formats: com.apple.developer.nfc.readersession.formats:
- NDEF - TAG