Mobile apps · SwiftUI

Docs › Mobile apps

SwiftUI

iOS 17 and later, as a Swift package with no dependencies.

Figma-style comments for a running iOS app. Tap an element, write a note, and it reaches your coding agent (Claude Code, Codex, Cursor, Gemini CLI, Copilot and others) over MCP with a screenshot, the Swift file and line it was written at (for views you mark), what VoiceOver would call it, and the app's recent log. It is the iOS client of the same Notato server the web, Android, .NET MAUI, React Native and Flutter SDKs use: the notes, the board, the MCP tools and the status loop (open, acknowledged, resolved, revert) are the same.

SwiftUI and UIKit apps on iOS 17 and later (and Mac Catalyst), as a Swift package with no dependencies.

Quick start#

npx notato dev           # the server your agent reads, on http://localhost:4747

Add the package: in Xcode, File › Add Package Dependencies… with https://github.com/notatorg/notato, or in a Package.swift:

.package(url: "https://github.com/notatorg/notato", from: "0.1.1"),

with .product(name: "Notato", package: "notato") in your target's dependencies. Then start it as early as the app starts:

import SwiftUI
import Notato

@main
struct ShopApp: App {
    init() {
        #if DEBUG
        Notato.start(NotatoConfiguration(project: "shop-ios"))
        #endif
    }

    var body: some Scene {
        WindowGroup { RootView() }
    }
}

Leaving Notato.start out of a build (as #if DEBUG does here) ships nothing of Notato in it. Run the app: a small dark toolbar appears in the corner. Tap Annotate, tap what you want to comment on, write a note, press Send. Ask your agent to "watch Notato and fix what comes in". Everything people write reaches the agent, unless they keep it between people (see People only and asides).

The app has to be allowed plain http to your machine: in Info.plist, NSAppTransportSecurity → NSAllowsLocalNetworking = YES. The simulator shares the Mac's network, so localhost works there as it is.

Where it is in the code#

Mark the views you care about, and notes about them (or anything inside them) say exactly where they are written:

struct ProductList: View {
    var body: some View {
        List { … }
            .notatoScreen()                 // a screen: its name is the note's route
    }
}

struct ProductCard: View {
    var body: some View {
        HStack { … }
            .notato("ProductCard")          // ProductList.swift:52, filled in by the compiler
    }
}

The file and line come from #filePath and #line, so there is nothing to keep in step. Paths are given from the repository root (found by looking for .git above the source file, which the simulator and a Mac can read; set sourceRoot for a device). .notatoMask() makes a view private (see What is never recorded); .notatoIgnore() makes the picker look through it.

Configuration#

In code, or from the app's Info.plist (a Notato dictionary) or a JSON file, with NOTATO_* environment variables over either (an Xcode scheme, or SIMCTL_CHILD_NOTATO_SERVER=… with xcrun simctl launch):

Notato.start()   // reads Info.plist and the environment
<key>Notato</key>
<dict>
    <key>Project</key><string>shop-ios</string>
    <key>Mode</key><string>dev</string>
    <key>Server</key><string>http://localhost:4747</string>
</dict>
Key (NotatoConfiguration)Default
Project (project)(required)Project id on the server. Letters, digits and . _ - @, not only dots
Mode (mode)devdev, test or agent (see below)
Server (server)http://localhost:4747 (none in test mode)An empty string means no server
Token (token)A project token (notato_…) for a shared notato serve. Sent only to the configured server (the same scheme, host and port); a server typed into Settings gets no token
Enabled (enabled)trueWhether Notato is on at launch. The app can switch it at runtime
ShowToolbar, ToolbarPositiontrue, bottomTrailingThe floating toolbar. People can drag it, and fold it into one round button with its chevron (it opens again while they annotate); where they leave it, and whether it is folded, is remembered
ShakeToToggletrueShaking the device shows or hides the toolbar (real devices)
AuthorThe name on this person's notes. They can change it in the toolbar's settings
ScreenshotstrueA server that has screenshots off wins either way
MaskInputson in test and agent modeCover text fields in screenshots and leave what is typed in them out of notes. Secure fields always are; .notatoMask(false) opts a field out
RememberRuntimeStatetrueKeep runtime choices (on or off, toolbar, name, a server typed in) across launches
ReadAccessibilitytrueRead the accessibility tree to describe what was tapped (see below)
CaptureLogs, LogLimittrue, 50Attach the app's own recent log messages (Logger, os_log)
SourceRootfound from .gitThe folder source paths are given from
AppName, AppVersionfrom the bundleRecorded on every note
MaxScreenshotScale2Phones are 3x; 2x is plenty to read and half the size

At runtime: Notato.shared#

Notato is @Observable, so a view can show and change its state directly:

struct DeveloperSettings: View {
    @State private var notato = Notato.shared

    var body: some View {
        Toggle("Notato", isOn: Binding(get: { notato.isEnabled }, set: { $0 ? notato.enable() : notato.disable() }))
    }
}
enable(), disable(), isEnabledOn and off. A choice made here wins over enabled in the configuration until resetRuntimeState()
showToolbar(), hideToolbar(), isToolbarVisibleThe toolbar. Off still lets the app drive Notato from code
startAnnotating(), stopAnnotating()The next tap selects what is under it
select("#AddToCart")Select an element as if it had been tapped, and open the note for it
annotate("#AddToCart", comment:, options:)Make a note with no UI, as a person or (with agentName) an agent. Throws if the server refuses it. AnnotateOptions(peopleOnly: true) keeps a person's note from the agent
annotations, pendingCount, connection, connectionDetailWhat Notato knows, for a status line or a badge
packageNotes(upload:)Test mode: the device's notes as a bundle zip, uploaded when a server is set

The sample's Feedback tab does each of these.

What a note carries#

The same schema as every Notato SDK (environment.platform is ios):

To record a session's requests, put the recorder in front of its protocols when the session is made:

let configuration = URLSessionConfiguration.default
#if DEBUG
configuration.protocolClasses = [NotatoNetworkRecorder.self] + (configuration.protocolClasses ?? [])
#endif
let session = URLSession(configuration: configuration)

What is never recorded#

TextField("Card number", text: $card).notatoMask()       // private in every mode
TextField("Search", text: $query).notatoMask(false)      // recorded even when MaskInputs is on

Reading the accessibility tree#

iOS builds an app's accessibility tree only when an assistive technology or a UI test asks for it. To read it, Notato does what UI-testing tools do: it loads UIKit's and SwiftUI's accessibility bundles into the app and switches on iOS's application accessibility. That setting belongs to the system, not the app, so Notato remembers what it was and puts it back when it is switched off or the app goes to the background (and on the next launch, if the app was killed first). It uses private system calls, which is fine for a debug tool and one more reason to keep Notato out of release builds. With ReadAccessibility off, Notato leaves the system alone and knows elements only through .notato() marks.

Selectors#

What a note's selector looks like, and what an agent passes to notato_annotate or annotate(_:):

#AddToCartThe accessibility identifier, or the name of a .notato("AddToCart") mark
buttonThe role (* for any)
:text("Add to cart")The label or value contains this, ignoring case
:nth(2)The second match on screen, in reading order
ProductDetail …A leading screen name, for people; ignored when matching. A name that is not one capitalised word is quoted: "My cart" button

People only and asides#

Everything people write reaches the agent: every note, and every reply on it. Two switches keep something between people:

Modes#

The toolbar#

The bar holds a grip (drag it, or the bar anywhere, to move it), Annotate with the count of notes on this screen, ⋯, and a chevron that folds it into a round button showing the Notato potato, with the count on its corner. Everything else is in the ⋯ sheet: Annotate, hide or show pins, the notes list, Package and share and Clear notes (test mode), Settings, Hide toolbar and Turn Notato off. Notes lists the newest 50 on this screen in pin order, and says how many older ones are here and how many are on other screens (the board has them all). A note's card shows the last 4 entries of its thread, with how many earlier ones are on the board. The ⋯ sheet's header shows the mode, the server and whether it is connected; when the server cannot be reached a banner says so, and Retry tries again at once instead of waiting for the next attempt. A dot on ⋯ (or on the round button) shows only while connecting or offline. The sheets follow the app's light or dark appearance; the bar is always dark.

Pins#

A pin is drawn for each note on the screen at its element (found again as the screen changes, or where the note was made, faded, when it is not there). Only notes made on iOS are pinned: a web or Android note whose route has the same name is about another app's screen, and is in the Notes list but not on this one. A screen shows the pins of its newest 150 notes at most, as the React Native and Flutter SDKs do; the Notes list and the board have every note.

How the overlay works#

Notato draws its UI in SwiftUI, in a window of its own above the app's window in the same scene. Touches that are not on Notato's controls fall through to the app; while annotating, or while one of its sheets is open, Notato's window takes them. Being a separate window keeps it above sheets, full-screen covers, alerts and popovers, and out of the screenshots, which are taken of the app's window only. While a note is being written Notato's window has the keyboard, and gives it back after.

Known limits#