TapBack is a SwiftUI app for iOS 27 that turns a physical NFC tag into a focus switch. Pair a tag to a named focus mode, choose the apps that mode shields, and tap the tag to toggle it. Scheduled modes keep working through a DeviceActivityMonitor extension when the app is not running. No backend, no account, no subscription. Everything runs and persists on device.
| Focus modes | Blocking active | Mode editor |
|---|---|---|
![]() |
![]() |
![]() |
| Schedule editor | Simulated tap | Authorization gate |
|---|---|---|
![]() |
![]() |
![]() |
Screenshots are captured on Simulator, where
FamilyActivityPickercannot mint application tokens. That is why every mode reads "No apps selected": the counts are honest rather than seeded. The tag pairings and schedules shown are real.
- Tap to toggle — pair an NFC tag to a focus mode and tap it to shield or unshield that mode's apps, via
NFCTagReaderSession. - Focus modes — named modes, each with its own app and category selection, an optional paired tag, and an optional schedule.
- App shielding —
FamilyControlsandManagedSettings, the Screen Time API, applied through a single sharedManagedSettingsStoreso the app and its extension agree on what is shielded. - Scheduled enforcement — a
DeviceActivityMonitorextension applies and clears shields on a recurring weekday window while the host app is not running. - Bluetooth companion — a second device running TapBack can advertise over
CoreBluetoothand drive the same toggle, because Apple does not expose Host Card Emulation for NFC. - Works without a tag —
MockNFCScannerand a Simulate Tag Tap control mean the whole scan, shield, and persist loop is developable and testable on Simulator. - Full VoiceOver support — each mode row is one spoken element describing its name, pairing, shielded content, and schedule; weekday buttons spell out full day names; decorative icons are hidden.
- Dynamic Type — semantic text styles throughout, exercised in previews and in UI tests that launch at
AccessibilityXL. - Reduce Motion — the pulsing status indicator is suppressed, not merely slowed.
- Centralized accessibility identifiers — a single source of truth, shared with the UI test target by a thin mirror that a test guards.
- Code quality enforcement through SwiftLint.
- Comprehensive test coverage — unit, UI, and accessibility tests, with unit tests run in CI.
- Swift 6 (language mode, complete strict concurrency)
- SwiftUI
- Core NFC (
NFCTagReaderSession) - FamilyControls, ManagedSettings, DeviceActivity
- CoreBluetooth
- Swift Concurrency: actors,
AsyncStream,Task,Sendable - Swift Testing framework (unit tests)
- XCTest and XCUIAutomation (UI and accessibility tests)
- App Groups for host-to-extension state sharing
- iOS 27 minimum OS target
A concurrency-agnostic domain core, a single actor owning mutable state, and a @MainActor UI layer over the top. Every collaborator sits behind a protocol so it can be replaced in tests, in previews, and on Simulator.
graph TD
subgraph UI["UI layer, @MainActor"]
CV["ContentView"]
MLS["ModeListSection"]
MEV["ModeEditorView<br/>+ ScheduleEditorSection"]
SS["ScanSection"]
CSEC["CompanionSection<br/>DEBUG only"]
AVM["AuthorizationViewModel"]
MLVM["ModeListViewModel"]
MEVM["ModeEditorViewModel"]
SVM["ScanViewModel"]
CVM["CompanionViewModel"]
end
subgraph Facade["Observable facade, @MainActor"]
MS["ModeStore"]
end
subgraph Core["State owner"]
MSA["ModeStoreActor"]
end
subgraph Services["Services, protocol-backed"]
AUTH["AuthorizationServiceProtocol"]
NFC["NFCScannerProtocol"]
COMP["CompanionTriggerProtocol"]
BLOCK["BlockingServiceProtocol"]
SHIELD["ShieldStoreProtocol"]
SCHED["ScheduleServiceProtocol"]
SYNC["SyncClientProtocol"]
end
subgraph Domain["Domain core, nonisolated and Sendable"]
FM["FocusMode<br/>Schedule.isActive"]
TAG["TagIdentifier"]
PMS["PersistedModeState"]
end
subgraph Storage["Persistence"]
REPO["ModeRepository"]
GROUP[("App Group<br/>group.com.tapback")]
end
subgraph Ext["TapBackMonitor, separate process"]
DAM["DeviceActivityMonitorExtension"]
end
CV --> AVM
MLS --> MLVM
MEV --> MEVM
SS --> SVM
CSEC --> CVM
AVM --> AUTH
MLVM --> MS
MEVM --> MS
MEVM --> NFC
SVM --> MS
SVM --> NFC
CVM --> MS
CVM --> COMP
CVM --> SYNC
MS --> MSA
MSA --> BLOCK
MSA --> SCHED
MSA --> REPO
MSA -.reads.-> FM
MSA -.reads.-> TAG
BLOCK --> SHIELD
SHIELD --> MSS[["ManagedSettingsStore"]]
SCHED --> DAC[["DeviceActivityCenter"]]
REPO --> PMS
REPO --> GROUP
DAC -. fires on schedule .-> DAM
DAM --> GROUP
DAM --> SHIELD
DAM -.evaluates.-> FM
And the path a tag tap takes, from antenna to shield:
sequenceDiagram
autonumber
actor User
participant Tag as NFC tag
participant Scanner as CoreNFCScanner
participant VM as ScanViewModel
participant Store as ModeStore
participant Act as ModeStoreActor
participant Block as BlockingService
participant Shield as ManagedSettingsStore
participant Repo as ModeRepository
User->>Scanner: taps Scan NFC Tag
Scanner->>Tag: NFCTagReaderSession polls
Tag-->>Scanner: didDetect(tag)
Note over Scanner: delegate callback bridged to async<br/>via a single-use CheckedContinuation
Scanner-->>VM: ScanEvent(tagIdentifier)
VM->>Store: toggleMode(forTagIdentifier:)
Store->>Act: await toggleMode(forTagIdentifier:)
Note over Act: the actor serializes every mutation,<br/>so concurrent NFC and Bluetooth<br/>taps cannot interleave
alt tag matches a configured mode
alt mode is currently active
Act->>Block: deactivate()
Block->>Shield: clear applications, categories, domains
else mode is inactive
Act->>Block: activate(mode:)
Block-->>Act: throws noAppsSelected if nothing is selected
Block->>Shield: apply tokens
end
Act->>Repo: save(PersistedModeState)
Note over Repo: synchronous inside the actor,<br/>so writes land in mutation order
Act-->>Store: state plus activated or deactivated
else no mode paired to that tag
Act-->>Store: state plus unassignedTag
end
Store-->>VM: TagToggleOutcome
Note over Store: Observable properties updated<br/>on the main actor, the view rerenders
VM-->>User: result alert
| Layer | Responsibility | Key types |
|---|---|---|
| Models | Codable, Sendable value types, plus the pure schedule predicate |
FocusMode, FocusMode.Schedule, BlockState, ScanEvent |
| Domain logic | Deterministic functions taking date and calendar as parameters | Schedule.isActive(on:calendar:), TagIdentifier, ScheduleConverter, SyncBackoff |
| State | Single owner of mutable state, serialized by actor isolation | ModeStoreActor, ModeStore |
| Services | Shielding, scanning, scheduling, authorization, companion triggers | BlockingService, CoreNFCScanner, DeviceActivityScheduleService, FamilyControlsAuthorizationService, BluetoothCompanionService, WebSocketSyncClient |
| Persistence | One value in, one value out, through a shared App Group | PersistedModeState, ModeRepository, UserDefaultsModeRepository |
| View models | Observable presentation state and all user-facing wording | ModeListViewModel, ModeEditorViewModel, ScanViewModel, AuthorizationViewModel, CompanionViewModel |
| Views | SwiftUI screens and sections | ContentView, ModeListSection, ModeEditorView, ScanSection, CompanionSection |
| Extension | Background enforcement in a separate process | DeviceActivityMonitorExtension |
-
A domain core that does not know about concurrency or time
The app target builds with
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so every model, protocol, and service the extension touches is explicitlynonisolated. That annotation turns out to be useful documentation: it marks exactly which types are safe to hand across the actor boundary and into another process.Time is always passed in.
Schedule.isActive(on:calendar:)reads nothing from the environment, which is what makes overnight windows, weekday boundaries, and the Saturday-into-Sunday wraparound testable without a device or a clock. -
One owner for mutable state
ModeStoreActorowns modes and active state;ModeStoreis the@MainActor@Observablefacade SwiftUI reads. Mutations are serialized by the actor, and persistence happens synchronously inside it so writes land in mutation order.Tag routing lives on the store, not in a view model, so NFC and the Bluetooth companion share one implementation of what a tag tap means.
-
Everything mockable, because everything has to be
Core NFC needs hardware. FamilyControls authorization always fails on Simulator.
ManagedSettingsStorecannot be read back.DeviceActivityCentercannot register anything on Simulator. Each of those sits behind a protocol, andAppEnvironmentpicks the implementation from launch arguments. That is what makes the app runnable on Simulator, the UI tests deterministic, and the screenshots reproducible. -
Error paths that reach the user
A mode with nothing selected cannot be shielded. That failure used to be swallowed by a
print, so the toggle silently snapped back with no explanation. NowBlockingError.noAppsSelectedreaches an alert that says what to do about it. -
Accessibility as part of the component, not a pass afterwards
Each mode row is a single spoken element combining name, pairing, shielded content, and schedule. Weekday buttons show one letter and speak the full day name, because "T" appears twice. The status pulse respects Reduce Motion. Every identifier comes from one enum.
You do not need an NFC tag to work on this.
Apple does not expose Host Card Emulation to third-party apps. Only Wallet, PassKit, and Apple Pay can emulate a card, and there is no API for the rest of us, so there is no way to turn a second iPhone into a scannable NFC tag. Instead:
1. The scanner is a protocol
protocol NFCScannerProtocol {
func scan() async throws -> ScanEvent
}Two implementations ship:
CoreNFCScanner, the real one, wrappingNFCTagReaderSessionMockNFCScanner, DEBUG only, with configurable success and failure behaviour and an optional delay so loading states are visible
AppEnvironment injects the mock automatically when NFCNDEFReaderSession.readingAvailable is false, which is always the case on Simulator. The Simulate Tag Tap row lets you type any identifier and drives the identical code path a real tap does. Every layer above Core NFC is exercised without hardware; real hardware only confirms that Core NFC itself wires up.
2. Use a second device over Bluetooth
Switch on Act as Companion on a second device running TapBack and it advertises a BLE service. The first device picks it up and routes received identifiers through the same toggle. This is the closest thing to a second-phone tag that iOS allows.
3. Check your wallet
Most people own something with a passive NFC or RFID chip that Core NFC can detect. Even without a clean NDEF payload, a detection callback firing is enough to sanity-check the plumbing: a contactless card, a transit card, an office badge, a hotel keycard, a gym or loyalty card.
- Xcode 27, iOS 27 deployment target
- A physical iPhone running iOS 27 for the full experience. Core NFC and FamilyControls both need real hardware
- An Apple Developer account. The free tier is enough for local development
- The Family Controls capability on both the app and the extension target. App Store distribution needs an entitlement request to Apple, but local development on your own device works without approval
- Any NFC tag or sticker, roughly five to ten dollars for a five pack. An existing transit or access card works for triggering a read
- Clone the repo and open
TapBack.xcodeproj - Confirm the Family Controls capability and the
group.com.tapbackApp Group on bothTapBackandTapBackMonitor - Build to a physical iPhone running iOS 27
- Grant Screen Time authorization on first launch
- Create a focus mode, pick apps, pair a tag, and tap
On Simulator the app runs with a stubbed authorization gate and the mock scanner. Use Simulate Tag Tap in place of a real tap. To seed example modes, launch with --uitesting --demo-data.
Focus modes, tag pairings, schedules, and the active mode are stored as one PersistedModeState value in a shared App Group container, group.com.tapback.
The App Group is about process boundaries, not secrecy. The DeviceActivityMonitor extension runs in its own process and must read exactly what the host app wrote.
Nothing stored is a credential. Mode names, weekday sets, times, and NFC tag UIDs are configuration and public hardware identifiers. Keychain would actually be worse here, because keychain access from a background extension is constrained by device lock state, and a monitor firing before first unlock after a reboot could fail to read its own schedule. If a real secret ever appears it gets partitioned into the Keychain and the configuration stays put.
Shields survive app termination and reboot, because ManagedSettingsStore persists independently of the host process. The extension can clear a shield the app applied, and the reverse, because both write to the same named store.
209 tests, all passing locally: 184 unit tests using Swift Testing, organised by feature, plus 25 UI and accessibility tests in XCTest.
| Suite | Covers |
|---|---|
ModeStoreTests |
Create, update, delete, toggle, schedule registration, restoring persisted state |
ModeStoreActorConcurrencyTests |
Twenty interleaved toggles, twenty-five concurrent creates, persistence ordering |
ModeStoreTagRoutingTests |
Tag matching, case and whitespace normalization, unpaired modes, duplicate pairings |
BlockingServiceTests |
The empty-selection guard, that it runs before any write, and idempotent clearing |
ModeRepositoryTests |
Round trips, the cleared-active-mode regression, dangling active IDs, suite fallback |
ScheduleTests |
Weekday and overnight windows, half-open boundaries, wraparound, degenerate schedules |
ScheduleConverterTests, ModeActivityNameTests |
DeviceActivity conversion and activity-name round trips |
ScanViewModelTests |
Every NFCScanError branch, silent cancellation, unpaired tags, simulated taps |
ModeEditorViewModelTests |
Validation, trimming, schedule building, create versus update, scan-to-pair |
ModeListViewModelTests, AuthorizationViewModelTests |
Pass-through state, error surfacing, prompt-once behaviour |
CompanionViewModelTests |
Companion tags routing through the same toggle as NFC, URL validation |
SyncBackoffTests |
Monotonicity, the ceiling, and overflow at Int.max |
ModelCodingTests, ScheduleSummaryTests |
Codable round trips for everything crossing the process boundary, and display formatting |
UI tests launch with --uitesting, which swaps in an in-memory store, a stubbed authorization gate, the mock scanner, and a no-op shielding layer, so every run starts clean and is reachable on Simulator. That last substitution matters: because no test can build a non-empty FamilyActivitySelection, the real BlockingService would correctly refuse to activate any seeded mode, and no activation flow would be testable. The guard it bypasses is covered directly in BlockingServiceTests.
TapBackUITestscovers list rendering, the empty state, create and cancel flows, save validation, schedule editing, simulated taps, and banner stateTapBackAccessibilityUITestscovers combined VoiceOver labels, full weekday names, spoken labels on icon-only buttons, and two launches atAccessibilityXLtestIdentifiersStayInSyncWithTheAppguards the hand kept identifier mirror
A test plan (TapBack.xctestplan) keeps unit tests parallel and UI tests sequential. ModeRepositoryTests is .serialized because it creates and tears down real UserDefaults suites.
.github/workflows/tests.yml runs on every push to main and every pull request targeting it. Two jobs: SwiftLint against .swiftlint.yml, and a build plus the unit tests via xcodebuild test -only-testing:TapBackTests against an iOS Simulator destination.
The test job resolves its own destination rather than pinning a device name, picking the newest installed iOS Simulator runtime and the plainest iPhone within it, and downloading the iOS platform first if the runner image ships without a simulator runtime.
UI tests are kept out of CI: they are slower and simulator-bound, and are run locally from the full test plan.
Tools/EchoServer is a small WebSocket echo server built on Network's NWListener and NWProtocolWebSocket, used to prototype connection handling for WebSocketSyncClient.
cd Tools/EchoServer
swift run EchoServer # listens on ws://localhost:8080
swift run EchoServer 9000 # or pick a port
Connect from the app's Realtime Sync section, then stop the server with Ctrl-C to watch the client walk up its exponential backoff, and start it again to watch it recover.
Swift rather than the more usual Node or Vapor, so the repo stays all-Swift with no package manager, no lockfile, and no dependency tree.
TapBack/
├── TapBack/
│ ├── App/
│ │ ├── TapBackApp.swift
│ │ ├── ContentView.swift
│ │ ├── AppEnvironment.swift # dependency graph + launch flags
│ │ ├── AccessibilityIdentifiers.swift
│ │ ├── DemoData.swift # DEBUG
│ │ └── PreviewSupport.swift # DEBUG
│ ├── Models/
│ │ ├── FocusMode.swift # + Schedule.isActive(on:calendar:)
│ │ ├── ScanEvent.swift
│ │ └── BlockState.swift
│ ├── Services/
│ │ ├── NFCScannerProtocol.swift
│ │ ├── CoreNFCScanner.swift
│ │ ├── MockNFCScanner.swift # DEBUG
│ │ ├── BlockingServiceProtocol.swift # + ShieldStoreProtocol
│ │ ├── BlockingService.swift
│ │ ├── ScheduleService.swift
│ │ ├── AuthorizationService.swift
│ │ ├── CompanionTrigger.swift # CoreBluetooth central + peripheral
│ │ ├── SyncClient.swift # WebSocket + SyncBackoff
│ │ └── ModeStore.swift # ModeStoreActor + ModeStore
│ ├── Features/
│ │ ├── Authorization/
│ │ ├── Modes/ # list, editor, schedule editor, summary
│ │ ├── Scan/
│ │ └── Companion/ # DEBUG
│ └── Persistence/
│ ├── ModeRepository.swift
│ └── InMemoryModeRepository.swift # DEBUG
├── TapBackMonitor/ # DeviceActivityMonitor extension
├── TapBackTests/
│ └── Mocks/ # blocking, shield store, schedule
├── TapBackUITests/
│ └── AccessibilityIdentifiersMirror.swift
├── Tools/EchoServer/ # SwiftPM WebSocket echo server
├── Screenshots/
├── .github/workflows/tests.yml
├── .swiftlint.yml
├── TapBack.xctestplan
└── README.md
- Persistence writes one value, not three keys. The original save encoded the active mode ID as a top-level
Optional, whichJSONEncoderthrows on. Thecatchswallowed it with aprint, so every time the active mode was cleared the throw aborted the remaining writes and the persisted state quietly drifted from what was on screen. It never announced itself because the modes write on the previous line had already landed. The repository now takes a singlePersistedModeState, encodes before writing anything, and stores the active ID as a plain string.clearingTheActiveModeStillPersistsTheModesfails against the old implementation. The wider lesson is that aprintin acatchis not error handling, it is a place for errors to go and die. - One daily
DeviceActivityScheduleper mode, not seven.DeviceActivitySchedulehas a single interval and no weekday selector. Registering one repeating interval and letting the extension consultSchedule.isActive(on:calendar:)keeps all weekday logic in one pure tested function and keeps registrations well under DeviceActivity's limit. The cost is a wasted wake-up on unscheduled days. - The extension is entirely synchronous. No
Task, noawait, and it does not use theBlockingServiceactor. Extensions have a small execution budget and can be killed mid-flight; anawaitbetween the check and the write is an invitation to leave a shield half applied. ShieldStoreProtocoloverManagedSettingsStore. The real store is a concrete final class with no way to read back what was applied. The wrapper is the only thing that makesBlockingServicetestable, and it costs one small struct.- Two paths are untestable, and are documented rather than faked.
ApplicationTokenis opaque and onlyFamilyActivityPickeron a device can mint one, so no test can build a non-emptyFamilyActivitySelection. Fabricating token JSON would produce a test asserting behaviour that has never run on a device. The gap is stated inBlockingServiceTests. - Date and calendar are always parameters, never read internally, trading a little verbosity for fully deterministic, time-zone-independent tests.
- A hand kept mirror of the accessibility identifiers in the UI test target. UI test targets cannot use
@testable import; a shared package would be the real fix and a mirror plus a guard test is the cheap one. - Companion and sync are DEBUG-only developer tooling. They prove the plumbing. A shipping Bluetooth feature would need pairing, identity, and a conflict story.
Released under the MIT License. © 2026 SarahUniverse











