Skip to content

Repository files navigation

TapBack app icon

TapBack

Tap an NFC tag to shield a set of apps. Tap again to unshield.

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.

Tests

Screenshots

Focus modes Blocking active Mode editor
Focus modes Blocking active Mode editor
Schedule editor Simulated tap Authorization gate
Schedule editor Simulated tap Authorization gate
Dark mode
Focus modes Blocking active Mode editor
Focus modes Blocking active Mode editor
Schedule editor Simulated tap Authorization gate
Schedule editor Simulated tap Authorization gate

Screenshots are captured on Simulator, where FamilyActivityPicker cannot 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.

Key Features

  • 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 shieldingFamilyControls and ManagedSettings, the Screen Time API, applied through a single shared ManagedSettingsStore so the app and its extension agree on what is shielded.
  • Scheduled enforcement — a DeviceActivityMonitor extension 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 CoreBluetooth and drive the same toggle, because Apple does not expose Host Card Emulation for NFC.
  • Works without a tagMockNFCScanner and 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.

Technologies

  • 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

Architecture

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
Loading

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
Loading
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

Focus areas

  1. 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 explicitly nonisolated. 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.

  2. One owner for mutable state

    ModeStoreActor owns modes and active state; ModeStore is the @MainActor @Observable facade 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.

  3. Everything mockable, because everything has to be

    Core NFC needs hardware. FamilyControls authorization always fails on Simulator. ManagedSettingsStore cannot be read back. DeviceActivityCenter cannot register anything on Simulator. Each of those sits behind a protocol, and AppEnvironment picks the implementation from launch arguments. That is what makes the app runnable on Simulator, the UI tests deterministic, and the screenshots reproducible.

  4. 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. Now BlockingError.noAppsSelected reaches an alert that says what to do about it.

  5. 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.

Testing NFC without buying anything

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, wrapping NFCTagReaderSession
  • MockNFCScanner, 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.

Requirements

  • 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

Getting started

  1. Clone the repo and open TapBack.xcodeproj
  2. Confirm the Family Controls capability and the group.com.tapback App Group on both TapBack and TapBackMonitor
  3. Build to a physical iPhone running iOS 27
  4. Grant Screen Time authorization on first launch
  5. 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.

Data and storage

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.

Testing

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.

  • TapBackUITests covers list rendering, the empty state, create and cancel flows, save validation, schedule editing, simulated taps, and banner state
  • TapBackAccessibilityUITests covers combined VoiceOver labels, full weekday names, spoken labels on icon-only buttons, and two launches at AccessibilityXL
  • testIdentifiersStayInSyncWithTheApp guards 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.

Continuous integration

.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.

Local echo server

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.

Repo structure

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

Trade-offs and decisions

  • Persistence writes one value, not three keys. The original save encoded the active mode ID as a top-level Optional, which JSONEncoder throws on. The catch swallowed it with a print, 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 single PersistedModeState, encodes before writing anything, and stores the active ID as a plain string. clearingTheActiveModeStillPersistsTheModes fails against the old implementation. The wider lesson is that a print in a catch is not error handling, it is a place for errors to go and die.
  • One daily DeviceActivitySchedule per mode, not seven. DeviceActivitySchedule has a single interval and no weekday selector. Registering one repeating interval and letting the extension consult Schedule.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, no await, and it does not use the BlockingService actor. Extensions have a small execution budget and can be killed mid-flight; an await between the check and the write is an invitation to leave a shield half applied.
  • ShieldStoreProtocol over ManagedSettingsStore. The real store is a concrete final class with no way to read back what was applied. The wrapper is the only thing that makes BlockingService testable, and it costs one small struct.
  • Two paths are untestable, and are documented rather than faked. ApplicationToken is opaque and only FamilyActivityPicker on a device can mint one, so no test can build a non-empty FamilyActivitySelection. Fabricating token JSON would produce a test asserting behaviour that has never run on a device. The gap is stated in BlockingServiceTests.
  • 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.

License

Released under the MIT License. © 2026 SarahUniverse

About

NFC-triggered app blocker for iOS 27, built with FamilyControls and Core NFC.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages