Mobile apps · SwiftUI

Docs › Mobile apps

SwiftUI

iOS 17 and later, as a Swift package with native WebP compression.

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 libwebp for screenshot compression.

Screenshot size. New captures prefer WebP at quality 85 when it is smaller than PNG, after privacy masking and at the same dimensions. PNG remains a fallback, and existing queues and bundles stay readable. See screenshot storage.

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.3.0"),

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". In Dev mode, your feedback is private to you and your agent. To share a note with the project, change its visibility in the board. See the feedback visibility guide and 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. NOTATO_SERVER wins over it (see below)
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. In a build you hand out, use a capture-only one (see Tokens in builds you hand out)
Enabled (enabled)trueWhether Notato is on at launch. The app can switch it at runtime; NOTATO_ENABLED wins over both (see below)
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. With a server set (test mode's too), none is taken until it has said it takes them (see Screenshots and the server)
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

For one launch: NOTATO_ENABLED and NOTATO_SERVER#

These two are read however Notato is started, from code too, so whoever launches the app decides for that launch without changing it: in an Xcode scheme (Run › Arguments › Environment Variables), with the SIMCTL_CHILD_ prefix for xcrun simctl launch, or from a launcher such as Aspire.

SIMCTL_CHILD_NOTATO_ENABLED=false xcrun simctl launch booted com.example.shop
NOTATO_ENABLEDtrue, 1, yes or on, or false, 0, no or off, in any case. Wins over Enabled, and over on or off chosen at runtime in an earlier launch. Anything else is ignored
NOTATO_SERVERThe server, such as http://localhost:4747. Wins over Server, and counts as the configured server, so Token goes to it. Blank is ignored (it never means no server)

enable() and disable() still work during the launch and are remembered as before, but the next launch with NOTATO_ENABLED set goes by it again; resetRuntimeState() goes back to it when it is set, else to the configuration. A server typed into Settings still wins over NOTATO_SERVER, as it does over Server.

Tokens in builds you hand out#

Anyone with a copy of an app can take a token out of it. In a build that leaves your machine (TestFlight, an ad hoc or enterprise build), use a capture-only app token for the one project, set to expire, made on the board or with:

npx notato token create shop-ios --capture-only --expires 30

A capture-only token can send new notes and nothing else: whoever takes it out of the app cannot read, change or delete anyone's notes or screenshots. Notato asks the server what its token may do (/config) before it connects, and with a capture-only one it only sends: notes go as they are made (or as soon as the server can be reached), the toolbar's menu says Send only, and there are no live updates, replies, revert requests or deletions from the app. A note's card says to read and answer it on the board. Test mode sends feedback live when a server is configured and also supports exporting and uploading a package. Keep a token without --capture-only for builds that stay with you.

Screenshots and the server#

The server decides whether screenshots are taken (notato config). With a server set, in test mode too, Notato asks it (/config) as it starts and again before each screenshot (unless it asked in the last few seconds, and waiting at most a second and a half), and takes none until the server has said it takes them: not while it has not answered yet, nor after it could not be asked again. A package leaves out the screenshots of its notes unless the server says it takes them. With no server set (Server empty, or test mode without one) nothing stops them but Screenshots.

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(); a launch with NOTATO_ENABLED set goes by that
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#

Replies reach the agent within its active feedback scope. In Dev mode, the agent starts with the creator's private feedback; Test mode feedback is Shared by default. People only and Aside are additional ways to keep shared feedback 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#

Hiding completed annotations#

On a resolved or reverted note, open More options → Hide from this app to hide its pin and card on this device. In Settings, Hide completed annotations hides completed notes across every screen at once, including notes beyond the 150 drawn pins. Restore hidden annotations brings them back.

Hiding keeps the feedback, screenshots and thread in the dashboard and exports. It never changes the note on the server. Reopened tasks and new agent questions remain visible. The preference is scoped to the server, project, mode, credential and name, and survives app restarts when runtime preferences are remembered.