Docs Agents
Agents#
How a coding agent, or a script, drives Tomacco. The JSON CLI, the MCP server and the desktop all call the same typed operations, so an agent can do everything the desktop can. Agent patterns shows how to plan, wait and recover with this contract.
On this page (32)
- Connect a client
- A shorter tool list
- Agent skill
- The contract
- Jobs
- Several steps in one call
- Cleaning up
- Bug reports
- Session log and replay
- Guardrails
- Android
- Swift on Android
- iPhone over USB
- On the connected iPhone
- Xcode projects, Flutter, crash reports and the App Store
- A Mac over SSH
- The virtual iOS device
- Deploying an app to the virtual iOS device
- Display sessions
- Windows
- Files
- Debugging
- UI tests and profiling
- Screenshot baselines in a folder
- Coverage, reports, history and affected tests
- Seeing the screen
- UI tests on the virtual iPhone
- App performance
- Release
- Updates
- Web layouts
- Research operations
tomaccoctl is a compiled Rust program. Run it directly, not through python3. Neither the CLI,
MCP, the build workers nor the installer (tomaccoctl _install) need Python; repository tests,
installing from a source checkout and the optional iOS preparation recipes do.
Connect a client#
tomaccoctl setup connects Claude Code and Codex through their mcp add commands, and Gemini CLI,
Cursor, VS Code, Zed and OpenCode by adding a tomacco entry to their JSON settings: ~/.gemini/settings.json
(mcpServers), ~/.cursor/mcp.json (mcpServers), ~/.config/Code/User/mcp.json (servers),
~/.config/zed/settings.json (context_servers) and ~/.config/opencode/opencode.json (mcp), with $XDG_CONFIG_HOME in place of ~/.config when it
is set. It asks first for each agent it finds and keeps
everything else in the file. A file with comments, such as Zed's usual settings, or a file that is a
symbolic link is left alone; setup prints the entry to add by hand. Only the file handling is tested,
on a scratch home directory; whether each agent then starts the server has not been checked here.
T3 Code and similar apps run these agents for you, so they use the
same connection: once setup has connected Claude Code, Codex, Cursor or OpenCode, a session T3 Code starts
with that agent should have Tomacco's tools. This follows from how T3 Code starts the agents and has not
been tried here.
By hand, register the absolute path of tomaccoctl:
claude mcp add --transport stdio --scope user tomacco -- "$(command -v tomaccoctl)" mcp
codex mcp add tomacco -- "$(command -v tomaccoctl)" mcp
For another user or an uninstalled checkout, give that absolute path instead. This is a local
stdio server: no network port and no token. The installer never changes client settings; only tomaccoctl setup does, after asking.
The installer does link the agent skill into the agents' skills directories. See the
Claude Code and
Codex documentation.
Any MCP stdio client gets the same tools. The resources tomacco://catalog, tomacco://jobs,
tomacco://workspaces, tomacco://debug and tomacco://runtimes expose profiles and recent work.
tomacco://capabilities is the result of the capabilities operation and tomacco://devices that of
device.list.
resources/templates/list names three resource templates that mirror read-only operations:
| Template | What it reads |
|---|---|
tomacco://jobs/{id} | The job as job.get answers it, as JSON. |
tomacco://jobs/{id}/log | The last 64 KiB of the job's log as text, secrets masked. job.logs reads the rest. |
tomacco://reference/{operation} | The operation as tomaccoctl help describes it, then its JSON Schema with its annotations. |
completion/complete completes {operation} with operation names and {id} with the IDs of the 100
most recent jobs.
The guides are resources too, as Markdown of the installed version: tomacco://docs/agents (this guide),
tomacco://docs/recipes, tomacco://docs/getting-started, tomacco://docs/android, tomacco://docs/iphone,
tomacco://docs/virtual-ios (its usage guide), tomacco://docs/troubleshooting and tomacco://docs/faq. Links
inside them are relative to the repository's docs/ directory.
The server also offers three prompts, short recipes a client can show as commands:
| Prompt | Arguments | What it asks the agent to do |
|---|---|---|
setup | none | Call doctor and work through next_steps in order, asking before each step marked ask_first. |
run-android-app | path | Inspect the project, find or start an Android device, project.run it, wait for the job and capture the screen. |
run-virtual-iphone | path | Check the virtual iOS device, find or build the xtool app, deploy it to a running phone or configure and start one, then look at the screen. |
- Every tool states all four MCP annotations:
readOnlyHint,destructiveHint,idempotentHintandopenWorldHint. They come from each operation'sannotationsintomaccoctl schema. - MCP requests can run at the same time.
notifications/cancelledstops that request. Cancellingjob.waitleaves its job running; usejob.cancelto cancel a build and its descendants.- A
tools/callwith_meta.progressTokengetsnotifications/progressonce a second while it runs. Forjob.waitthe message is the job's state and its latest log line, andtotalis the wait in seconds. A call that finishes within a second gets none. - After
logging/setLevelwithinfoordebug, a runningjob.waitsends each new line of its job's log asnotifications/message, levelinfo, loggerjob ID. Secrets are masked as injob.logs. Until the client sets a level, the server sends no log messages. - A tool result also comes as
structuredContent: an object result as it is, any other result as{"result": ...}, and an error as{"error": {...}}. The text content is unchanged. - Native screenshots up to 4 MiB are embedded as MCP images. Larger ones keep a path and digest.
- A client that declares
elicitation(form mode, protocol 2025-06-18 or later) is asked to put a yes-or-no question to the person, withelicitation/create, before these steps:android.sdk_installwithoutaccept_licenses(a yes sets it; a no installs without accepting, as before),play.commit,ios.store_shipwithuploadandapple.storewithupload(a no stops the call), and anios.virtual.setupstage markedask_first(a yes repeats the call withconfirm:true). A no answersconsent_declined, with the question and the client'sactionunderdetails. A question waits up to 10 minutes. A client without elicitation is never asked, and these calls behave as from the CLI.
A shorter tool list#
The full tool list is every operation with its whole schema, 321,785 bytes of tools/list response in this
version. For an agent with a small context, set TOMACCO_MCP_TOOLS in the env of the client's server entry:
| Value | Tools | tools/list response |
|---|---|---|
full (the default) | One per operation, with every description. | 321,785 bytes |
compact | One per operation; each description is its first sentence, arguments have no descriptions, and annotations equal to MCP's defaults are left out. | 157,922 bytes |
grouped | One per area, such as android or job: 29 tools. Each takes operation, the operation's name, and arguments, its own arguments, and its description lists the area's operations with their titles and whether each is read-only, writes or is destructive. | 33,148 bytes |
The sizes were measured with this version's schema. In compact and grouped there is also a schema tool that
answers one operation's whole schema, as does the resource tomacco://reference/{operation}. A grouped tool's
annotations are its area's strictest: destructiveHint when any of its operations is destructive, so a client that
asks before destructive tools asks for the whole area. Calling an operation by its own name works in every mode.
An unknown value is the full list, with a warning on standard error.
Agent skill#
An agent only uses Tomacco when it knows Tomacco is there, and it cannot hold every operation in context. The agent skill is built for that: it loads in three steps, and an agent reads only as far as the task needs.
- The description (always loaded) names the tasks Tomacco is for, in every area: Android and iOS apps, emulators, the virtual iPhone, device screenshots and logs, understanding a screen with marks and OCR, UI tests, store screenshots, performance with macrobenchmark and baseline profiles, release signing and upload, and Omarchy.
SKILL.md(loaded when the task matches) is a router of about 1,600 words: how to reach Tomacco over MCP (and thefull,compactandgroupedtool lists) ortomaccoctl, how jobs work (start,job.wait, read the result), the safe way to work (capabilitiesfirst, annotations, ask before destructive calls, policy and approval answers), a table from task to area, the core loops, and what to do with anerror.codeand itsremedy.workflows.mdhas the call sequences for the common jobs, andreferences/has one file per area with every operation's title, description, arguments and an example call,errors.mdwith every error code and its remedy, andindex.mdwith the operation count of each file. An agent reads the one file it needs.
references/ is generated by python3 tools/build_reference.py from assets/operations.json and
assets/errors.json, together with reference/, so it changes with the schema and a test
fails when it is stale. A test also checks that every operation is in exactly one references file and that every
operation and argument that SKILL.md and workflows.md name exists. The source is
integrations/agent-skill/tomacco.
The installer (tomaccoctl _install, or install.py) installs the whole skill directory, including references/, in ~/.local/lib/tomacco/skills/tomacco and links it as tomacco into
~/.claude/skills (Claude Code), ~/.codex/skills (Codex) and ~/.agents/skills (read by other agents), for each of
~/.claude, ~/.codex and ~/.agents that exists. It makes the skills directory when it is missing. An entry
named tomacco that is not a link to the installed skill is left alone and reported under agent_skill.skipped in
the installer's output. The links are recorded in the install journal: --uninstall and --rollback remove exactly
the links and the skills directories an installation made. An update keeps the skill current through the same
links and adds none that you removed. --no-agent-skill installs the skill without linking it, and removes links an
earlier installation made.
tomaccoctl setup offers the same link, asking first, for each of those agents that has its directory but no link,
for example an agent installed after Tomacco. tomaccoctl uninstall also removes those links.
The install, update, uninstall and rollback steps are tested in a scratch home directory with stand-in agent directories. Whether each agent then picks the skill up has not been checked here.
The contract#
Do not guess operation names or arguments. tomaccoctl schema prints every operation with its
JSON Schema: arguments, defaults, enums and bounds. tomaccoctl schema OPERATION prints only that
operation, as result, and tomaccoctl schema --names prints just the names, one per line, as plain
text. Unknown arguments are refused before dispatch.
A mistyped operation name or argument key gets a remedy naming the closest match, such as
Did you mean project.run? or Did you mean arguments.path?. With no close match, the remedy for an
unknown key lists the keys that are accepted. The operation reference is the same
schema as Markdown pages, generated for this version.
tomaccoctl schema
tomaccoctl schema project.run
tomaccoctl schema --names
tomaccoctl doctor
tomaccoctl call device.list '{}'
tomaccoctl call project.inspect '{"path":"/absolute/project"}'
tomaccoctl call project.plan '{"path":"/absolute/project","platform":"android"}'
tomaccoctl call project.execute @/absolute/build-request.json
Arguments are inline, from a file (@FILE) or from standard input (@-). A response is one JSON
object on stdout, {"ok":true,"result":...} or
{"ok":false,"error":{"code":...,"message":...,"remedy":...}}. An error exits nonzero. The
error reference lists every code with what it means and what to do. In MCP mode,
standard output has protocol messages only. Opening or inspecting a project never runs its build
scripts.
When standard output is a terminal, the JSON is indented for reading. Through a pipe or into a file it
is one line, exactly as before. --pretty indents it anywhere, and --compact keeps one line at a
terminal.
For people typing commands, tomaccoctl help OPERATION (or tomaccoctl call OPERATION --help)
describes one operation in plain text: what it does, each argument with its type, choices, limits and
default, whether it changes anything, and an example call built from the schema. man tomaccoctl
describes the commands and flags; the installer and cmake --install put the page in share/man/man1.
tomaccoctl completions bash, zsh or fish prints a completion script
for the commands, their flags and the operation names after call, schema and help. Load it with
source <(tomaccoctl completions bash), the same for zsh, or tomaccoctl completions fish | source.
The script asks tomaccoctl schema --names for the names, so it stays current after an update. The
bash script is tested; the zsh and fish scripts have not been run in those shells yet.
To plan, tomaccoctl call capabilities says in one call what this computer can do right now:
under android its build, emulator (with its AVDs) and devices, and under ios its build
(xtool), devices and virtual_iphone (configured, running, phase). Each has ready and missing, the
names of what it lacks, such as kvm or xtool; doctor's next_steps say how to set them up. It
runs doctor's tool checks, so it takes a few seconds. Over MCP its schema has an outputSchema, and the same result is the
resource tomacco://capabilities.
Use TOMACCO_HOME=/absolute/test-state to isolate job, toolchain, profile and emulator state. The
desktop's All tools tab shows every operation and its schema.
Jobs#
Long operations return a job, and queued or running is not success. Keep the id, which is in
result.id.
tomaccoctl call job.wait '{"job_id":"JOB_ID","seconds":30}'
tomaccoctl call job.logs '{"job_id":"JOB_ID","offset":0}'
tomaccoctl call job.get '{"job_id":"JOB_ID"}'
job.wait waits at most 60 seconds per call, and answers running as a valid result: repeat it, or
read job.logs with next_offset, until the state is succeeded, failed, cancelled or
timed_out. Then check the exit status and artifacts.
From the CLI, --wait does that loop for you:
tomaccoctl call project.execute '{"path":"/absolute/project","platform":"android"}' --wait
The job's log goes to standard error as it grows, and standard output gets one envelope at the end. A
job that succeeded is {"ok":true,"result":JOB} with exit status 0. One that ended failed,
cancelled or timed_out is an error with code job_failed, job_cancelled or job_timed_out,
the final job in error.details.job, and exit status 1. A result that is not a job is printed as it
is. Interrupting --wait leaves the job running.
- Artifacts carry an absolute path, size, SHA-256 and
modified_in_job. An old output is not evidence of a new build after a failed job. Incremental builds can legitimately reportmodified_in_job:false. - Durability. Jobs persist in SQLite and survive a client disconnecting, but a sandbox or process supervisor can still kill their workers.
- Cancelling.
job.cancelends the process group. - Workspace locks. Build, run and task jobs lock their project while they run. An identical
active request reuses its job, and different work in the same project answers
workspace_busywith the active job. A Flutter run holds the lock until it exits. Keep independent projects in separate directories. Commands you run outside Tomacco are not covered. - Retries.
android.sdk_install,ios.wda_prepareandandroid.connecttakeretry: {"count":N,"backoff_seconds":S}: after a failure they try again up toNmore times (at most 5), waitingSseconds (5 by default), doubled each time up to 600. A job repeats all its steps, never after being cancelled or timing out, lists each attempt underresult.attemptsand has aretryevent per new attempt.android.connectrepeats onlyadb connect, never pairing, and returnsattempts. Other operations do not takeretry, because repeating them is not known to be safe. - Heavy jobs wait their turn. At most two builds run at once:
project.execute,project.run(except a Flutter session) andios.flutter_build. Others stayqueued, withphase:"queued"and aqueued_reasonnaming the jobs that hold the slots, and start when one ends. Emulators thatandroid.avd_startstarts can be capped as well, for as long as they run; they have no cap by default.settings.configure {"jobs":{"max_builds":N,"max_emulators":N}}changes the caps, and0removes one. A queued job can be cancelled, and its timeout starts only when it runs. - Finding jobs.
job.listreturns the newest jobs first and takesstate(a list),operation(a prefix of the operation that started the job, such asproject.),project(an absolute directory the job locks or runs in),since(seconds since 1970) andlimit. For the next page, pass the id of the last job asbefore. Jobs started by earlier versions have no recorded operation, so anoperationfilter leaves them out. - Timeline.
job.eventslists a job's state changes, phase changes and cancellation requests in order, each with atimein seconds since 1970; a state that ended carriesexit_codeanderror. Pass the returnednext_afterasafterto read only what is new. Jobs created by an earlier version have only their creation and end, markedsynthesized.
Several steps in one call#
workflow.run runs up to 20 operations in order and answers with each step's result. A step that starts
a job is followed until the job ends, or until its app is up (phase ready, as a Flutter run that keeps
its job is), unless the step has "wait":false. The first step that fails, or whose job does not
succeed, stops the rest: the answer is workflow_step_failed with every step so far under
details.steps and the operations not run under details.skipped. Unknown operations and wrong
arguments are refused before the first step runs.
tomaccoctl call workflow.run '{"steps":[{"operation":"project.execute","arguments":{"path":"/absolute/project","platform":"android","action":"build"}},{"operation":"job.get","arguments":{"job_id":{"from_step":0,"pointer":"/result/id"}}}]}'
An argument value {"from_step": N, "pointer": "/result/..."} is replaced by that JSON pointer into
step N's record; for a followed job, /result is the finished job, so /result/artifacts/0/path is
its first artifact. A destructive step, such as device.install, and one that publishes (play.commit, an
upload with ios.store_ship or apple.store) runs only with "confirm":true on that step; without it
nothing runs and the answer is workflow_needs_confirmation. Set it only after the
person agreed. timeout (1800 seconds by default) covers the whole workflow; when it runs out, the
current job keeps running. The call holds its MCP request or terminal until it ends, so for a single
long build project.execute with job.wait is just as good.
Cleaning up#
Job records and logs stay in the data directory (TOMACCO_HOME, by default ~/.local/share/tomacco) until you remove them.
storage.prune lists what is older than older_than_days (30 by default): records and logs of jobs that ended, log files without a
job, and report directories under reports/. max_job_log_mb also lists the oldest finished jobs until their logs fit. It is a dry run
that changes nothing and reports the size of each entry of the data directory under usage. Show the user the candidates, and only then
repeat the call with apply:true. Queued and running jobs, emulators, SDKs, the virtual iOS device, settings and the artifacts a job built
in a project are never removed.
tomaccoctl call storage.prune '{"older_than_days":14}'
tomaccoctl call storage.prune '{"older_than_days":14,"apply":true}'
Bug reports#
For a bug report, diagnostics.bundle (or tomaccoctl report [FILE]) writes one .tar.gz: version, host,
doctor, settings, and the record, timeline and log end of the most recent jobs that did not succeed
(jobs, 10 by default; log_kb, 64 by default). It sends nothing. Secrets of known shapes are masked, job
environments keep only their variable names and the home directory becomes ~. Tell the user where it is
and that they should read it before sharing it.
tomaccoctl report /absolute/tomacco-report.tar.gz
Session log and replay#
Set TOMACCO_SESSION_LOG to an absolute file path, in the shell or in the env of your MCP client's server entry, and every
operation called through tomaccoctl call or tomaccoctl mcp is appended to that file as one JSON line: time, operation,
arguments with recognised secrets masked, mutating (the operation is not read-only), ok, duration_seconds, and a short
result (a job's ID and state, an object's keys, a list's length) or the error code and message. The file is created owner-only.
Calls an operation makes itself, and the job polling of call --wait, are not logged.
tomaccoctl replay FILE calls the logged operations that change something again, in order, waits for each job one of them starts,
and stops at the first failure. It skips, and lists with the reason, calls that only read (read_only), that failed when they were
logged (failed_originally), job.* calls, which name jobs of the old session (job_reference), and calls whose arguments were
masked (redacted). --dry-run lists what it would call without calling anything.
TOMACCO_SESSION_LOG=/absolute/session.jsonl tomaccoctl call project.run '{"path":"/absolute/MyApp","platform":"android","device":"emulator-5554"}'
tomaccoctl replay /absolute/session.jsonl --dry-run
tomaccoctl replay /absolute/session.jsonl
Guardrails#
Every operation called through tomaccoctl call or tomaccoctl mcp passes a guard first. Nothing is configured
by default, so an agent can do what the operations allow, as before. The person sets agent_policy from a terminal,
with settings.configure; over MCP that call is refused, so an agent cannot loosen its own policy.
tomaccoctl call settings.configure '{"agent_policy":{"mcp":"no_destructive","clients":{"codex":"read_only"},"policies":{"builds":{"mode":"no_destructive","allow":["project.","job.","device.list","capabilities"],"deny":["project.watch"],"write_roots":["/home/me/src"]}},"approval":{"destructive":true,"timeout_seconds":30},"limits":{"jobs_per_session":4,"inline_result_kib":1024}}}'
tomaccoctl call agent.policy '{"channel":"mcp","client":"codex"}'
tomaccoctl call audit.list '{"channel":"mcp","limit":20}'
- Policy.
fullallows everything,no_destructiverefuses operations markeddestructiveHint, andread_onlyallows only those markedreadOnlyHint.policiesdefines named ones: amode(one of the three),allowanddenylists of operation names, prefixes ending in a dot (play.) or*. A deny entry always wins; with an allow list, no other operation runs (the MCP resources, which only read, stay readable). The steps ofandroid.eachare checked like calls. An MCP server's policy is, in this order, the onetomaccoctl mcp --policy NAMEorTOMACCO_POLICYnames, the client's entry inclients(by theclientInfoname it sends ininitialize),mcp, thenfull;tomaccoctl callusesTOMACCO_POLICY, thencli. An unknown name allows nothing. A refused call ispolicy_denied, before anything runs;agent.policysays what a caller gets. - Write roots. Under any policy but
full, and a named one unless itswritesisanywhere, a destination an operation takes (a screenshot, report, export, keystore,files.write's project,project.create's folder) must be inside Tomacco's data directory, the temporary directory, the directory the server was started in (unless that is/or the home directory or above it), the policy'swrite_rootsorTOMACCO_WRITE_ROOTS(directories separated by colons). Links are followed before the check. settings.json, the audit log and saved results stay closed. Otherwise the call ispath_outside_roots. What a build's own scripts write is not checked. - Approval at the desktop. With
approval.destructive, a destructive call from an MCP agent waits until the person at the Tomacco desktop approves it in a dialog that shows the agent's name, the operation and its arguments with secrets masked. Deny, Escape and the end oftimeout_seconds(5 to 30, 30 by default) are a no:approval_denied. Without a desktop open the call runs, unlesswithout_desktopisdeny.tomaccoctl callis not asked, nor is the desktop itself. - Limits. An MCP session runs at most
jobs_per_sessionjobs at once (8 by default, 0 for no limit); one more issession_job_limit. A result larger thaninline_result_kib(2048 by default) is not sent: it is saved, and theresult_too_largeerror names aresult_idthatresult.readreads back in pieces. The call itself ran. - Audit log. Each call that may change something, and each refused call, is appended to
audit/audit.jsonlin the data directory (owner-only): the time,channel(cliormcp),client(the MCP client's name, orTOMACCO_CLIENTfortomaccoctl call), the operation, its arguments with secrets masked and anytextreplaced by its length and SHA-256,ok, the errorcode, the job ID, the policy and the approval. Past 4 MiB the file moves toaudit.1.jsonl; four older files are kept.audit.listreads it, newest first."audit":falseturns it off.
These guard what an agent does through Tomacco's operations. An agent that can also run shell commands as you can edit settings.json or run the same tools without Tomacco; the security policy says what is and is not covered.
Android#
Full guide: Android. Check prerequisites with doctor, android.sdk_packages and android.avds.
tomaccoctl call project.create '{"path":"/absolute/NewApp","kind":"android","name":"NewApp","organization":"dev.example"}'
tomaccoctl call project.execute '{"path":"/absolute/NewApp","platform":"android","action":"build","format":"apk"}'
tomaccoctl call device.list '{"platform":"android"}'
tomaccoctl call project.run '{"path":"/absolute/NewApp","platform":"android","device":"emulator-5554"}'
"kind":"compose" creates a Kotlin and Jetpack Compose app with one Compose UI test instead; run it
with "action":"instrumented-test". project.inspect reports a Gradle app's application ID, variants
and SDK levels under android, read from its files. project.dependency_updates reports which of a
Gradle build's dependencies and plugins, and of a project's pub, npm (or pnpm and Yarn) and SwiftPM
dependencies, have newer versions, as a read-only job. project.analyze runs dart analyze, ESLint and tsc
as the project has them and lists their findings with file, line, severity and code; see
Android.
project.watch takes the arguments of project.run and keeps the app current while you edit: a job that runs the app once,
then polls the project's files and, after they have stayed unchanged for debounce_ms (800 by default), calls project.run
again and waits for it. Build output, dependencies and tool state (build, .gradle, .dart_tool, node_modules, Pods,
.git and similar) are not watched; ignore adds directory names. A Flutter project gets one Flutter session and Hot Reload on
each change instead, and a new session when that one has ended. Each cycle writes one line to the job's log, such as
cycle 3: 1 file changed (app/src/main/java/Main.kt): built, installed and launched in 41.2 s (job ID). Cancel the job to stop;
the run it started is cancelled with it.
tomaccoctl call project.watch '{"path":"/absolute/NewApp","platform":"android","device":"emulator-5554"}'
tomaccoctl call job.logs '{"job_id":"WATCH_JOB_ID"}'
With device:"virtual-iphone" an xtool project is rebuilt and deployed on the running virtual iPhone on each save (see
deploying an app); xtool, .xtool and .bsp (xtool's output) are not watched, and a phone that is off is refused
before the job. Its line reads like cycle 2: 1 file changed (Sources/MyApp/ContentView.swift): built in 38.0 s, deployed in 24.1 s, pid 612, no new crash report in 10 s; succeeded after 74.9 s (job ID), or unchanged, opened in 3.2 s when the signed app did not change.
Unit-tested; not run on a live phone.
A Flutter session from project.run takes job.send with reload (Hot Reload), restart (Hot Restart) or quit, and while it
runs job.get shows its Dart VM service and, when Flutter serves them, DevTools URIs under result.flutter. A React Native or Expo
debug run takes reload through Metro's /reload endpoint. See Android.
Then look: device.capture for evidence, android.hierarchy for the UI tree, and assert the app's state
after each action. Prefer testing.find and testing.action over coordinates when the app exposes
accessible controls.
Before UI tests or screenshots, android.settings fixes dark mode, font scale, animations, airplane
mode and an app's language, and reads them back:
tomaccoctl call android.settings '{"device":"emulator-5554","dark_mode":"off","font_scale":1,"animations":false}'
android.store_screenshots captures store screenshots as a job across devices, app languages and light and dark
mode, each screen reached by its own testing.run steps, into fastlane supply's folders
(THEME/LOCALE/images/phoneScreenshots/01_home.png), and puts each device's settings back afterwards.
report.json flags sizes Google Play refuses under play_problems.
android.display locks the orientation or overrides display size and density, and reset undoes
both.
android.notifications lists the notifications shown, for asserting that the app posted one, and
android.app_files lists or pulls a debuggable app's private files.
device.info sums up an Android device under health: Android version and security patch, battery,
free storage, screen size and density, and uptime. device.label gives a device a nickname or pins it;
device.list then shows the nickname as name and lists pinned devices first.
android.pair pairs a phone over Wi-Fi by QR code: show the person the code it returns, then call it with
action wait until it pairs.
android.each runs steps such as install and launch on every connected Android device as one job,
with each device's result in the job's report.json.
android.package_inspect reads what a built APK or AAB declares, without a device: package,
version, SDK levels, permissions, ABIs and signing certificate digests.
android.size_report breaks one down by size per category, ABI and density, estimates the download, and compares with a baseline build.
device.install also takes one .aab and installs it through bundletool.
For the emulator's settings, android.avd_configure changes a stopped emulator:
tomaccoctl call android.avd_configure '{"name":"TomaccoQA","gpu":"host","vulkan":"off","memory_mb":4096,"cores":4}'
tomaccoctl call android.avd_start '{"name":"TomaccoQA","headless":true}'
android.emulator with action stop saves a Tomacco-managed emulator's Quick Boot snapshot first, and the next
android.avd_start resumes from it; its result's boot says quick or cold and why. cold_boot:true boots fully.
camera_image makes the emulator's camera show a PNG or JPEG, for apps that scan QR codes or documents. A foldable
profile such as pixel-11-pro-fold gives the AVD a hinge. See Android.
android.sensors drives an emulator's simulated sensors: turn it by gravity, shake it, cover the proximity sensor,
set the light, touch the fingerprint sensor for a biometric prompt, or fold a foldable:
tomaccoctl call android.sensors '{"device":"emulator-5554","action":"orientation","orientation":"landscape"}'
tomaccoctl call android.sensors '{"device":"emulator-5554","action":"fingerprint"}'
android.app_links reads an app's App Links verification per domain, asks Android to verify them again, says which
app a link opens (resolve), and approves or denies domains by hand for tests; android.deep_link opens the link and
names the activity that took it.
android.network_capture captures an emulator's traffic, or what one app sends, after opt_in:true, and summarises
hosts, TLS server names, HTTP requests without their query and DNS queries. See
Android for what it records and what it needs.
Swift on Android#
Full guide: Swift on Android. The tested toolchain is Swift 6.4 and NDK 30. Wait for
swift.android_configure before you build.
tomaccoctl call settings.configure '{"android_ndk":"/absolute/Android/Sdk/ndk/30.0.16248370","swift_android_sdk":"swift-6.4.0-RELEASE_android"}'
tomaccoctl call swift.android_configure '{}'
tomaccoctl call project.create '{"path":"/absolute/SwiftCounter","kind":"swift-android","name":"SwiftCounter","organization":"dev.example"}'
tomaccoctl call project.execute '{"path":"/absolute/SwiftCounter","platform":"android","action":"build"}'
tomaccoctl call swift.execute '{"path":"/absolute/SwiftCounter/Swift","platform":"linux","action":"test"}'
tomaccoctl call project.run '{"path":"/absolute/SwiftCounter","platform":"android","device":"emulator-5554"}'
iPhone over USB#
Full guide: iPhone. A physical iPhone has not been qualified. Start with ios.doctor. Connect
runtime.start before project.run, so the runner does not take the foreground afterwards.
tomaccoctl call ios.prepare '{"action":"login"}'
tomaccoctl call ios.sdk_install '{"path":"/absolute/Xcode.xip"}'
tomaccoctl call project.create '{"path":"/absolute/MyiOSApp","kind":"xtool","name":"MyiOSApp","organization":"dev.example"}'
tomaccoctl call project.execute '{"path":"/absolute/MyiOSApp","platform":"ios","format":"ipa","sign":true}'
tomaccoctl call runtime.start '{"platform":"ios","device":"ACTUAL_UDID","runner_bundle_id":"INSTALLED_RUNNER_ID"}'
tomaccoctl call project.run '{"path":"/absolute/MyiOSApp","platform":"ios","device":"ACTUAL_UDID"}'
tomaccoctl call ios.ipa_inspect '{"path":"/absolute/MyiOSApp.ipa"}'
ios.ipa_inspect needs no device: read its warnings before an install fails on an expired profile or the wrong
bundle id. It reads what the files claim and verifies no signature.
This route builds xtool's SwiftPM format; project.import_xcode writes it for an .xcodeproj. Tomacco neither ships nor downloads the SDK.
Read Xcode's license first.
On the connected iPhone#
Guide. Tested with stand-ins only; no iPhone was connected.
tomaccoctl call ios.device_status '{"device":"ACTUAL_UDID"}'
tomaccoctl call device.logs '{"platform":"ios","device":"ACTUAL_UDID","bundle_id":"dev.example.MyiOSApp","duration":60}'
tomaccoctl call ios.signing '{"device":"ACTUAL_UDID","path":"/absolute/MyiOSApp"}'
tomaccoctl call device.app '{"platform":"ios","device":"ACTUAL_UDID","bundle_id":"dev.example.MyiOSApp","action":"stop"}'
tomaccoctl call ios.app_files '{"device":"ACTUAL_UDID","bundle_id":"dev.example.MyiOSApp","action":"pull","path":"Documents","output":"/absolute/documents.tar"}'
tomaccoctl call testing.run '{"path":"/absolute/ui-tests","directory":"/absolute/ui-results","devices":["ACTUAL_UDID"],"platform":"ios"}'
tomaccoctl call ios.record '{"device":"ACTUAL_UDID","path":"/absolute/screen.mp4","seconds":20}'
ios.device_statussays whether the phone is paired, trusted, unlocked and in Developer Mode. Show the user itsnext_steps: the steps on the phone (tap Trust, unlock, turn on Developer Mode) only they can do.device.logswithbundle_idkeeps that app's process; the job names it asprocess.ios.signinglists profiles on the phone and in the project with expiry, team and devices, and the signing certificates they name. Readwarnings: a free account's profile lasts 7 days.device.applaunches, stops and uninstalls with go-ios;ios.appshas one plain row per app underinstalled.ios.app_fileslists or pulls a development build's container asandroid.app_filesdoes.testing.runwithplatform:"ios"runs scenarios through the WebDriverAgent of each iPhone'sruntime.startsession; start that first.resource_idis the accessibility identifier,tapandswipetake points, and the table in the guide shows the rest.ios.recordrecords the screen to an MP4 as a job from go-ios's screenshot stream;job.sendstopkeeps what was recorded.device.screenshotfalls back to go-ios whenidevicescreenshotis missing.
Xcode projects, Flutter, crash reports and the App Store#
Full guide, with what was run for real and what was not:
iPhone. These call a pinned commit of Joshua Warren's
omarchy-apple-dev, which nothing fetches until ios.toolkit_install is called. ios.doctor reports its state under
linux_toolkit, and each thing in missing has a remedy. Nothing runs sudo or downloads from Apple.
tomaccoctl call ios.toolkit_install '{}'
tomaccoctl call project.import_xcode '{"project":"/absolute/MyApp.xcodeproj"}'
tomaccoctl call ios.crash_reports '{"device":"ACTUAL_UDID","pattern":"MyApp*","directory":"/absolute/crashes"}'
tomaccoctl call ios.crash_symbolicate '{"report":"/absolute/crash.ips","binaries":["/absolute/MyApp.app/MyApp"]}'
tomaccoctl call ios.toolkit_install '{"toolchain":true,"xcode_xip":"/absolute/Xcode.xip"}'
tomaccoctl call ios.store_ship '{"path":"/absolute/MyiOSApp"}'
tomaccoctl call ios.flutter_setup '{}'
tomaccoctl call ios.flutter_build '{"path":"/absolute/flutter_app"}'
project.import_xcodereturns at once withadapter,warningsand thenextcall. Tell the user about the warnings: each is something the Xcode project does that the adapter does not.ios.toolkit_installwithtoolchaintakes minutes and about 15 GB, and withoutxcode_xipsucceeds withsdk_ready:falsein its report. Do not fetch Xcode for the user. An SDK fromios.sdk_installdoes not count for this private toolchain.ios.flutter_setupandios.flutter_buildare untested in Tomacco: they ran only against fakes. Setup downloads about 7 GB and changes one file in the Flutter install, so ask first.ios.store_shipwithuploadpublishes to the user's App Store Connect account, so ask first. A live upload has not been qualified.
A Mac over SSH#
Full guide: the Mac route. For Xcode projects, Apple's Simulator, Flutter iOS and store
validation, with a Mac you supply. Typed operations are apple.devices, apple.project, apple.build, apple.test,
apple.archive, apple.export, apple.simulator, apple.device, apple.profile and apple.store.
tomaccoctl call remote.configure '{"name":"mac","host":"my-ssh-mac","directory":"/Users/me/Projects/MyApp"}'
tomaccoctl call remote.check '{"name":"mac"}'
tomaccoctl call remote.sync '{"name":"mac","path":"/absolute/MyApp"}'
tomaccoctl call remote.run '{"name":"mac","argv":["xcodebuild","-list"]}'
The virtual iOS device#
Experimental. Full guides: what it is, setup, usage. It needs the user's own restore images and an Image4 boot ticket (IM4M).
Can this machine run it? ios.virtual.host_check answers before anything is built: architecture, processors and
VT-x/AMD-V, /dev/kvm, memory, free disk on the filesystem of directory (or the profile's installs), Vulkan drivers
and graphics memory, the helper's APFS module against the running kernel, and passt. Each entry in checks has a
status of pass, warn or fail and a remedy; ready is false when one fails. It is read-only.
Will the configured phone start? ios.virtual.doctor adds the profile to those checks, in the same shape: every
input profile.json names, whether the guest tools and the engine were built from this checkout's sources, the saved
states' disks, how the last run ended, whether the next start's disk copy fits, and the graphics card and host pressure
right now. Also read-only.
Setting it up. ios.virtual.setup {work} with the person's files plans the whole chain from what is already done (stages, next,
the person's files under inputs, the machine under host); step:"run" starts the next stage as a job and skips done
ones. Its refusals are ios_setup_* codes with a remedy: ios_setup_needs_input names the person's files a stage lacks,
ios_setup_confirm means ask the person first, ios_setup_manual is the restore, run by hand from commands.
No profile yet? python3 tools/ios_virtual_profile.py --check lists every input of a start that is missing or
wrong, in chain order, each with the command that makes it. The JSON on stdout has them under missing (input, flag,
path, problem, make). With everything present, --directory /absolute/profile plus --cryptex, --boot-inputs,
--kernel, --tools and --install writes profile.json and state.json, never over an existing profile, and returns
the ios.virtual.configure call to make next. The user supplies the restore images, already decrypted, and one IM4M. No
tool makes those. Stop and tell the user when the IM4M is what is missing, rather than working around it.
Driving it. ios.virtual.status reports the shared state; ios.virtual.start and ios.virtual.stop are durable jobs. See
start and stop.
display_visible:falsemeans iOS took the launch but every frame stayed black. That is a display defect, not a working phone.ios.emulator.controlis input and frames, on theguest_jobofios.virtual.status. Poll it withaction:"frame"andtransport:"file"to watch the guest; see touch and gestures.ios.virtual.screenshot {path, frame}saves the screen as a PNG, withframeroundedordevicefor round corners or a plain bezel.ios.virtual.record {path, seconds, fps}is a job that records it to an MP4 with ffmpeg; see screenshots and recordings.ios.virtual.configure {model}selects the iPhone for the next start.ios.virtual.statushas the ids, and also reports the current run'srunning_panelasWIDTHxHEIGHT@HZ:WIDTH_MMxHEIGHT_MM.ios.virtual.statusreports the time-zone outcome astime_zone, andinstalled_applications(bundle identifier to the cdhash on the current run's volume) next toapplications, the profile's own bundles.ios.virtual.appsjoins both in one list, with each bundle'sversion,buildandminimum_osread on the host,installed,opens_at_start, and installed apps the profile no longer lists (listed:false). It asks no guest.- During a start,
phase_elapsed_seconds,phasesand, whilepreparing,copy_progressshow where it is.start_disk.enoughfalse means the next start will be refused withios_virtual_disk_space; its remedy says how much to free.ios.virtual.cleanup(a dry run unlessdry_run:false) deletes older disk copies; ask the user before deleting. ios.virtual.syslog {process, levels, contains, offset}reads the phone's console log as lines withprocess,pid,levelandmessage; passnext_offsetback asoffsetto follow it.bundle_idkeeps one listed app's lines: its executable's own (the name comes from the bundle on the host) and launchd's about its service, by name, so they continue across relaunches.ios.virtual.profileslists the profile directories configured so far;ios.virtual.configure {directory}switches between them only while the current phone is off (ios_virtual_runningotherwise).ios.virtual.state_save {name}keeps the last verified run under a name without copying it,ios.virtual.stateslists the names,ios.virtual.state_use {name}makes one the base of the next start (the phone must be off) andios.virtual.state_delete {name}removes a name and the copy only it kept; ask the user before deleting. See saved phone states.- A window for the phone:
preview.configurewithios_guest:{detached:true}. Asked while no guest is running, the request is kept for ten minutes.workspace.ios_research.guest.detach_pendingin the returned state tells whether it is still waiting.
Guest operations. ios.emulator.guest {job_id, operation, argument}, with job_id being the guest_job. They are inspect,
status, lookup, cancel, install, register, open, logs (development, quiet, buffer_texture and the owned app's
logs), setup, setup_finish, restart_ui, flush, effects, screen_state, display_state, foreground_state,
display_refresh, graphics_state, surface_state, crash_reports, persistence_probe, watchdog_scale, icon_cache, time_zone,
auto_lock, receive_bundle, policy_add, debug_server, network_state, hardware_probe, gestalt, developer_mode and
keyboard_layout. tomaccoctl schema has each argument. Some that need more words:
crash_reportstakes any report-name prefix, so a developer's app's reports are read like iOS's, and tomaccoctl projectscrash_report_identitiesfrom the report's own process name.ios.virtual.crash_reports {process, directory}saves the same reports as files on the host and lists their paths forios.crash_symbolicate. A run's reports reach the next run's disk only when that run became the base, so read them during the run.logs quietreads tomacco-quiet's record: the services it unloaded, its locationd guard's questions and the processes it ended.time_zone Area/Citysets the guest's zone through iOS's own tzlink service, and answersprevious_linkandlink.auto_lock never(ororiginal) sets Settings' Auto-Lock and reads it back. The start sends it by itself to a guest that reportsauto_lock_supported.network_statelists the interfaces' IPv4 addresses and tries one name lookup;hardware_probecounts an IOKit class's services;gestaltreads one MobileGestalt key;developer_mode status/enablereads or arms iOS's Developer Mode;keyboard_layoutreports the guest's language.receive_bundle,policy_addanddebug_serverare the guest side ofios.virtual.deployandios.virtual.debug; use those operations, not these directly.
The run's evidence (userspace_failures, respawn_storms) lists a locationd the guard ended as kind guard-ended, an expected
entry. A widget extension ended by chronod's deadline is accounted only for iOS's own extensions, and Settings' or a developer's app's
SIGTRAP stays unaccounted, so a clean reading of a run is not mistaken for a clean session.
Deploying an app to the virtual iOS device#
Two things decide before the boot whether an executable runs in this guest: its code-directory hash must be in the trust cache the guest booted with, and installd admits an ad-hoc signed app only through the development-signing adapter's policy. Both belong to the guest-tools build, so a developer's app is staged into that build and installed by the start. The rules for a bundle are in usage; every refusal names the file and the reason.
A SwiftUI app to run there comes from one job. project.app then names the bundle, says whether the rules below accept it, and returns the
calls that follow:
tomaccoctl call project.create '{"path":"/absolute/MyApp","kind":"xtool","name":"MyApp","organization":"dev.example","build":true}'
tomaccoctl call project.app '{"path":"/absolute/MyApp"}'
On a phone that is already running, one call installs and opens the app without a reboot:
tomaccoctl call ios.virtual.deploy '{"bundle":"/absolute/MyApp.app"}'
tomaccoctl call job.logs '{"job_id":"DEPLOY_JOB","offset":0}'
It signs a copy, exchanges spare trust-cache entries of the boot for its hashes while the guest is briefly stopped, sends the bundle over
the guest channel, names it to the signing adapter, installs it through installd and opens it (about 25 s). The profile lists the app
afterwards, so the next start carries it too. When this run already installed the same signed app (the same cdhash after signing, which
covers every file of the bundle), the deploy log has unchanged after signed, nothing is sent and the app is only opened; force:true
installs it again. This skip has unit tests only; it was not run on a live phone.
From an xtool project in one job. project.run with device:"virtual-iphone" builds with xtool dev build, deploys the one
xtool/*.app with ios.virtual.deploy (which opens it) and, after crash_check_seconds (default 10, 0 skips it), reads the app's
crash reports. A report that was not there before the deploy makes the job fail as crashed. It is refused before any job with
ios_virtual_not_running when the phone is off, and with action_unsupported for a project that is not an xtool project.
tomaccoctl call project.run '{"path":"/absolute/MyApp","platform":"ios","device":"virtual-iphone"}'
tomaccoctl call job.wait '{"job_id":"RUN_JOB","seconds":60}'
The job writes build/tomacco/virtual_iphone_run.json in the project and lists it in its artifacts: state (launched, crashed,
failed, or building when the build failed), build_seconds, deploy with its job_id, seconds, unchanged and steps, the
opened app's pid, and crash_check with the new reports as testing.crashes events. No crash report never proves the app did not
crash. The last log line says the same in one line. Unit-tested with recorded replies; not run on a live phone. ios.virtual.configure with add_application and deploy does the add and the deploy in one
call. A boot has 512 spare entries and the adapter's per-boot table holds 32 apps, frameworks and extensions. To debug the app, build it
with get-task-allow and run ios.virtual.debug {target} — but iOS refuses it with Developer Mode off and arming it did not hold across a boot, so
lldb cannot attach yet.
Otherwise, usually add_application, application and ios.virtual.start are enough, because the start builds what it needs. The sequence
with the optional build by hand:
tomaccoctl call ios.virtual.configure '{"add_application":"/absolute/MyApp.app"}'
tomaccoctl call ios.emulator.build_guest_tools '{"trustcache":"/absolute/system.tc","output":"/absolute/guest-tools-new","linker":"/absolute/aarch64-apple-darwin-ld","ldid":"/absolute/ldid","clang":"/absolute/clang-22.1.8/clang","minimum_ios":"27.0","agent":true,"application_probe":true,"development_signing":true,"quiet_services":true,"applications":[{"bundle":"/absolute/MyApp.app"}]}'
tomaccoctl call job.wait '{"job_id":"BUILD_JOB","seconds":60}'
tomaccoctl call ios.emulator.report '{"job_id":"BUILD_JOB"}'
tomaccoctl call ios.virtual.configure '{"application":"dev.example.MyApp"}'
tomaccoctl call ios.virtual.start '{}'
tomaccoctl call job.logs '{"job_id":"START_JOB","offset":0}'
tomaccoctl call ios.virtual.status '{}'
tomaccoctl call ios.emulator.guest '{"job_id":"GUEST_JOB","operation":"logs","argument":"development"}'
ios.virtual.configure {add_application}inspects the bundle and adds it to the profile.add_entitlementsnames a plist, andremove_applicationtakes the path or the bundle identifier.- Optional, because the start does this by itself:
ios.emulator.build_guest_toolswithapplications(requiresdevelopment_signing, and with itagentandapplication_probe) signs each bundle ad hoc, adds its hash to the trust cache and its identity to the adapter's table. Repeatjob.waituntil terminal, then readios.emulator.report. Name the finished build astoolsinprofile.json: the start installs the build the profile names. ios.virtual.configure {application}chooses the app the start opens, by bundle identifier. Without it the built-in diagnostic app opens.ios.virtual.startinspects each bundle again. When the profile's build does not carry one with the same content and entitlements, it builds a set that does, before any disk copy, into the profile'sguest-toolsdirectory, and reuses a set built from the same inputs. A kernel that does not pin the new adapter is replaced by a derived one, andprofile.jsonnames both, with the previous file kept beside it. The job log hasguest-tools,kernelandprofilelines. Clang 22, the linker andldidcome fromprofile.jsontoolchain, the settings'executables, or the build's record; a missing one fails the start and says where it looked. After the guest service answers, each app whose hash the base does not record is installed: phaseinstalling, oneapplicationevent per bundle,installedorinstalled-before. An install is never sent twice. installd's refusal ends the start with its reason, and the guest keeps running for diagnosis.- Read the outcome from
ios.virtual.status:phase,error,application,application_launch_accepted,application_process,display_visibleandinstalled_applications. A verified stop carries that record into the base, so a later start installs only an app whose signed executable changed. ios.emulator.guest logs developmentshows the adapter'sactiveline withentries, and anentryline per further identifier. This tells an inactive signing library from a refused bundle.crash_reports <executable name>reads the app's own reports.
Embedded frameworks and libraries have been installed and loaded on the virtual device. App extensions are verified offline only:
ios.emulator.guest lookup with the app's bundle identifier answers plugins, the extensions iOS registered. SwiftUI built by Tomacco's own
route is not supported here.
Display sessions#
runtime.start, runtime.list, runtime.state, runtime.audio, runtime.input, runtime.capture, runtime.hierarchy and runtime.stop work
on a live device, and runtime.start connects Android or a physical iPhone directly on Linux. Passing remote selects a Simulator on a configured Mac.
There is one session per device. Repeated calls return it with reused:true, and different encoder or runner settings answer
runtime_configuration_conflict.
Start with workspace.context for the selected project and runtime.list for what is connected. desktop.open with tab:"runtime" lets the
user watch the same app you are controlling.
When the desktop on the session is not the installed version, for example the one that ran while an update was installed,
desktop.open asks it to quit, starts the installed one and reports the old version as replaced_version (null for a desktop from
an earlier release, which does not say). A desktop with unsaved files stays, with desktop_unsaved; one that does not quit within 10 seconds
gives desktop_stale.
Input. runtime.input coordinates run from 0 to 1. Android supports tap, touch (down, move, up, cancel), swipe, key, text and rotate.
The Simulator also supports fill, pinch, shake and hinge when its companion does. Apple text sends ASCII keys, and fill replaces an
accessible field at x/y with any Unicode. Apple rotation needs an explicit orientation. Pass the current decoded image size for mapping after a rotation.
Evidence. runtime.capture and runtime.hierarchy give real app and OS evidence. device.capture waits for a screenshot, and
device.screenshot is the asynchronous form.
Sockets. Most agents should use the operations; the desktop uses these to avoid a process per pointer move.
video_socketemits repeated u32 big-endian length + payload.codecselectsh264(Annex-B) ormjpeg(one JPEG per payload). It can have several consumers, and a slow one is disconnected rather than growing memory.control_sockettakes one JSON line per connection and is owner-only:{"operation":"input","arguments":{"session":"SESSION_ID","action":"tap","x":0.5,"y":0.5}}
Stopping. runtime.stop is idempotent. It releases the session's processes, sockets and forwarding, and leaves the app and device running.
Windows#
desktop.open with device_only:true, then preview.configure with native:{device, connect:true, detached:true}, shows a device as its own window.
preview.state reports the desktop's mode, native runtime and decoded-frame metadata.
Files#
files.list, files.read, files.search, files.write and files.mkdir stay within an explicit project root and never execute source text. When
overwriting, pass the digest from files.read as if_match; a conflict leaves the user's edit intact. The desktop's editor uses this same contract.
Debugging#
debug.start starts a durable session: JDWP for Android and Java, Android NDK LLDB, or local LLDB. Then use debug.breakpoints, debug.state,
debug.events, debug.stack, debug.variables, debug.evaluate and debug.control, and stop it explicitly. The desktop's Debugger view can take
over the same session.
JDWP evaluation supports field paths, not arbitrary Java. LLDB capabilities depend on the adapter. A Linux Swift debug run does not qualify iOS remote debugging, and Swift expressions inside an Android process are not qualified.
For Android C/C++, configure android_ndk and lldb-dap through settings.configure, install a debuggable APK, then:
tomaccoctl call debug.start '{"backend":"android-lldb","device":"emulator-5554","bundle_id":"dev.example.app","program":"/work/app/native-symbols/libapp.so","launch":true}'
programis the local unstripped ELF from the installed build, with the same ABI and build id.symbol_pathsadds library directories,source_maprelocates source paths, andpidpicks a process.launch:truestarts a missing app and attaches, and keeps a running one. It does not stop before early native initializers.- It uses Android's
run-asgate and needs neitheradb rootnor changed security settings. - An identical request reuses the session. A different one on the device answers
device_debug_busy, across JDWP and LLDB; mixed debugging is not supported.debug.controlwithaction:"disconnect"releases it and keeps the app running.
Native debugging is qualified on API 36 x86_64. ARM64 is built but not run on a physical phone.
UI tests and profiling#
testing.setup installs a small instrumentation companion for Unicode, emoji and whole-field replacement, without changing the user's keyboard. testing.run
runs JSON scenarios on a bounded parallel device matrix, returning JUnit results and capturing failures; examples/native-navigation.json shows the format.
Besides the selector steps, a scenario can tap or swipe at pixel coordinates (x, y, end_x, end_y, duration in
milliseconds) and type text into the focused field.
With platform:"ios" the same scenarios run on USB iPhones through WebDriverAgent; see
UI tests on an iPhone.
testing.record writes such a scenario from what you do. Between start and stop, the taps, swipes, text and keys sent to that
Android device through android.input, runtime.input (also from the desktop's live display) and testing.action become steps. Before
each tap it reads the UI hierarchy and names the element under the finger by resource ID, description or text, adding index when the
name is shared; without a name there it records a tap at pixel coordinates. Reading the hierarchy makes a tap take longer while
recording. A rotation or another input a scenario cannot express is listed under skipped. status counts the steps, discard throws
the recording away.
tomaccoctl call testing.record '{"device":"emulator-5554","action":"start","name":"Add a counter"}'
# tap and type in the desktop or with android.input / runtime.input
tomaccoctl call testing.record '{"device":"emulator-5554","action":"stop","path":"/absolute/add-counter.json"}'
Read the scenario before you keep it: coordinate steps depend on the screen size, and a recorded step waits only when it is a click on a named element, which waits for that element.
testing.lint checks a scenario without acting on the device: its format, then each step against the screen now, or against a saved
android.hierarchy XML given as hierarchy. A finding names the step, a severity and a code: not_found (nothing on this screen
matches; a warning, since an earlier step may open the screen it belongs to), ambiguous (several elements match: an error for
click, long_click and fill, which act on one, with up to five candidates in steps), disabled, present (an element
expected absent is there), off_screen and invalid_key. passed is false when a finding is an error; a scenario that does not
parse answers valid:false with the error.
tomaccoctl call testing.lint '{"path":"/absolute/add-counter.json","device":"emulator-5554"}'
path can also be a directory: each .json file directly in it is a scenario, run in name order, and a malformed one is refused
before anything runs. By default every device runs every scenario. shard:true splits them instead: each scenario runs once, on
whichever device is free next, and the results are merged into one report.json (each result names its scenario and device) and
one junit.xml (classname is the scenario). Only the first parallelism devices take part, so raise it to use more.
tomaccoctl call testing.run '{"path":"/absolute/ui-tests","directory":"/absolute/ui-results","devices":["emulator-5554","emulator-5556"],"shard":true,"parallelism":2,"retries":1}'
retries runs a failed scenario again on that device, up to five times; a scenario's own retries field takes precedence. Each
device's result in report.json then lists its attempts (whether each passed, its error and the index of the step that failed), and
flaky:true marks a pass after a failure; the report's flaky lists each such scenario and device. In junit.xml the earlier failures are
flakyFailure elements of a passed test case, or rerunFailure elements of a failed one, as Maven Surefire writes them. A retry hides
nothing: read the attempts of a flaky scenario.
testing.results reads JUnit XML into one summary: totals (tests, passed, failed, errors, skipped, time), each suite with
its device for instrumented tests, and each failure with its class, name, message and stack. Given a project it finds Gradle's unit test
results (build/test-results) and instrumented test results (build/outputs/androidTest-results); a directory or a single file works too,
such as a testing.run report directory. Pass the test job's job_id (or since) so that results left from an earlier run are not
mistaken for this one's; files older than that are counted in older_files_skipped, and when nothing newer is left it answers
no_test_results. report writes the summary as JSON as well.
tomaccoctl call project.execute '{"path":"/absolute/MyApp","platform":"android","action":"test"}'
tomaccoctl call testing.results '{"path":"/absolute/MyApp","job_id":"JOB_ID","report":"/absolute/test-summary.json"}'
Flutter tests read the same way. project.execute with "action":"test" runs flutter test with its JSON reporter writing
build/tomacco/flutter_test.json beside the readable log, and testing.results on the project reads it: one suite per test file,
each failure with its test's line, and golden_failures, every failed golden-file comparison with flutter_test's message and the
paths of the masterImage, testImage, isolatedDiff and maskedDiff images it wrote. Any flutter test --machine or
dart test --reporter json output file works as path too. See Android.
testing.crashes reads the device's crash, system and main logs and returns Java crashes (FATAL EXCEPTION), ANRs and native crashes
(Fatal signal) as events: kind (crash, anr or native_crash), time, package, process, pid, and for a crash the
exception, message, stack and caused_by, for an ANR the reason, for a native crash the signal. bundle_id keeps one app's
events. since is a time in seconds since 1970 on the device's clock; job_id takes the window of a job, such as a project.run, on the
computer's clock, which an emulator shares. Without either it reads the last lines log lines.
tomaccoctl call testing.crashes '{"device":"emulator-5554","bundle_id":"dev.example.newapp","job_id":"JOB_ID"}'
testing.run does the same for each device after its scenario: the device's result in report.json lists crashes, and a crash or ANR
of an app the scenario launches, stops or clears fails it with app_crashed.
testing.visual_diff compares a screenshot with an approved baseline PNG, pixel by pixel. It answers changed_fraction of the compared pixels,
changed_bounds and passed (changed_fraction at most threshold), and with diff writes the baseline faded with changed pixels in red.
tolerance ignores small colour differences per channel, and ignore leaves out regions such as the status bar clock. A missing baseline
answers baseline_missing; look at the image, and only if it is right call again with update_baseline:true to keep it. Images of different
sizes fail with size_mismatch:true.
tomaccoctl call testing.visual_diff '{"actual":"/absolute/screen.png","baseline":"/absolute/baselines/home.png","diff":"/absolute/home-diff.png","threshold":0.001,"tolerance":8,"ignore":[{"x":0,"y":0,"width":1080,"height":64}]}'
testing.accessibility audits the Android screen's UI hierarchy, from a device or a saved hierarchy file with its density. Each finding
has a rule, severity, message, the element's class, id, text and description, its bounds in pixels and its size in dp:
missing_label(error): a clickable, long-clickable or checkable element with no text or content description, itself or inside it.small_target(warning): an enabled touch target narrower or lower thanmin_target_dp, 48 by default.small_text(warning): a text element lower thanmin_text_height_dp, 14 by default. The hierarchy has no font sizes, so this is an estimate from the element's height, which includes padding: it finds tiny text, not all small text.
An element cut off at the edge of a scrolling list is not judged by size. bundle_id keeps only the app's elements, without the status bar. These are heuristics, not a certificate: contrast, font sizes and focus
order are not checked.
tomaccoctl call testing.accessibility '{"device":"emulator-5554","bundle_id":"dev.example.newapp"}'
Screenshot baselines in a folder#
Give testing.run a baselines directory and screen steps need no absolute path: a relative baseline such as
login/home.png is a file in it, and a step without one gets SCENARIO/LABEL.png (the scenario's name and the step's
label, in lower case with dashes), or SCENARIO/step-NNN.png without a label. Keep the directory in the project.
A missing baseline fails the step with baseline_missing after its screenshot is taken. With record_baselines:true
such a step keeps its screenshot and the scenario goes on, so one run collects every new screen; the scenario then
fails with baselines_missing, which lists them.
testing.baselines works on the run's report directory. review lists the screens to look at: each with an id, the
scenario, device, step and label, its baseline, the screenshot as actual, the difference image and
changed_fraction, and a status: new, changed or size_changed; all_screens:true adds the passed, failed
and not_reached ones. Look at the images, then approve the right ones by screens (their ids), or every new and
changed screen with all:true. Approve copies each screenshot over its baseline, and refuses with
baseline_conflict before writing anything when two devices would write different images to one baseline. list
shows the PNGs in a baselines directory with their size in pixels.
tomaccoctl call testing.run '{"path":"/absolute/ui-tests","directory":"/absolute/ui-results","devices":["emulator-5554"],"baselines":"/absolute/MyApp/baselines","record_baselines":true}'
tomaccoctl call testing.baselines '{"run":"/absolute/ui-results"}'
tomaccoctl call testing.baselines '{"action":"approve","run":"/absolute/ui-results","screens":["s000-004@emulator-5554"]}'
Coverage, reports, history and affected tests#
testing.coverage reads the JaCoCo XML coverage reports of a project's tests: the ones the Android Gradle plugin
writes for unit tests (enableUnitTestCoverage) and instrumented tests (enableAndroidTestCoverage), the jacoco
plugin's and Kover's, which has the same format. project.execute with coverage:true runs the report tasks; see
Android. The answer has totals (instruction, branch, line,
complexity, method, class, each covered, missed, total and percent), each report with its kind and
module, files with their line and branch coverage and missed_lines as ranges such as 9-28, and classes, least
covered first, limit of each. Several reports, such as a module's unit and instrumented tests, are merged line by
line: a line is covered when any report covers it, and each class comes from the report that covers it most.
report writes the full result as JSON; give that file as baseline to a later call, and delta lists the change of
each total and each file whose line coverage moved, appeared or went away, most dropped first. minimum (percent
of lines) and max_drop (percentage points) set passed. Pass the test job's job_id so older reports are left out.
tomaccoctl call testing.coverage '{"path":"/absolute/MyApp","job_id":"JOB_ID","report":"/absolute/coverage-main.json"}'
tomaccoctl call testing.coverage '{"path":"/absolute/MyApp","job_id":"JOB_ID","baseline":"/absolute/coverage-main.json","max_drop":0.5}'
Every testing.run writes report.html beside report.json, and every test or instrumented-test job of
project.execute (and testing.impact) writes build/tomacco/test-report.html in the project, also when tests fail;
both are listed among the job's artifacts. The file is self-contained: inline styles, images embedded as data URIs up
to 48 MiB in all (beyond that linked by path) and no scripts. A run's report has each scenario and device with its
outcome, attempts, steps and their times, screenshots, baselines, difference images, crashes and metrics; a project's
has the totals, suites, every test case and each failure with its stack and Flutter golden images. testing.report
writes one for any such path, at output if given.
The same runs add their outcomes to a test history in Tomacco's data directory (testing/history.jsonl): one entry
per scenario and device, or per test case. testing.history with "action":"record" adds a testing.run directory or
the current results of a project, directory or file, each file version once. The summary lists each test with
runs, passed, failed, skipped, passed_after_retry, pass_rate, flips (changes between pass and fail),
recent (P passed, R passed after a retry, F failed, S skipped, newest last), last_failure and flaky
(both outcomes among its last last runs, or a retried pass), flaky tests first. name, suite, by_device,
flaky_only and min_runs narrow it; store names another history file.
tomaccoctl call testing.history '{"flaky_only":true,"min_runs":5}'
testing.impact picks the tests a change likely affects in a Gradle build. The changed files come from git diff
against base (HEAD by default, so uncommitted and untracked files) or from files. A changed test class is
selected itself. A changed Kotlin or Java file selects the unit and instrumented test classes of its module, and of the
modules that depend on it through project(...) or projects.x, that are named after it (NameTest, NameTests,
NameIT, NameSpec, TestName) or use its name in its package or with an import; when none does, the tests of its
package in its module. Resources or the manifest select the module's instrumented tests, a module's build file all its
tests and its dependents', and the settings, versions catalog, gradle.properties, gradle/, buildSrc or
build-logic everything (run_all). scenarios, a scenario file or directory, adds the testing.run scenarios that
launch an app whose module a change touches. commands are the Gradle calls (--tests for unit tests, the
instrumentation runner's class argument for instrumented tests, one call per module); run unit or instrumented
starts them as a job. unmapped lists files no rule covers. These are rules over the files, without Gradle: a test that
reaches the code through reflection, dependency injection or generated code can be missed, so run everything before a
release.
tomaccoctl call testing.impact '{"path":"/absolute/MyApp","scenarios":"/absolute/ui-tests"}'
tomaccoctl call testing.impact '{"path":"/absolute/MyApp","run":"unit"}'
What ran, with an isolated Tomacco data directory, on a copy of examples/hello-android given a Counter class, a unit
test and an instrumented test, and a temporary API 36 x86_64 emulator: test and instrumented-test jobs with
coverage and their test-report.html; testing.coverage on both reports, merged, and against a saved baseline
(line coverage 11.54 % to 19.23 %); testing.impact on an uncommitted change to Counter and its test, which selected
CounterTest, CounterDeviceTest and the example's scenario and listed a notes file as unmapped, and its run:"unit"
job; the history of those jobs and of four testing.run runs, which listed the scenario as flaky (FFPF, 25 %). A scenario with two unnamed assert_screen steps and
record_baselines failed with baselines_missing, approve with all kept both screenshots, and the next run passed
with 0 changed pixels; after one baseline was replaced by the other screen, the run failed with that step changed
(0.24 % of the pixels), its report.html embedded the screenshots, baselines and difference images, and approving
that one screen fixed the baseline. Kover and the jacoco plugin are unit-tested only.
Seeing the screen#
Eight operations help an agent understand what is on a screen instead of guessing coordinates. They work on an Android
serial and on virtual-iphone; a USB iPhone is not covered. On Android they read the UI hierarchy. The virtual iPhone has
none, only pixels, so there they read the screenshot with Tesseract OCR. Install tesseract and its English data for
that (capabilities returns ocr with ready and missing; TOMACCO_TESSERACT names another executable). Every
result that came from OCR says source:"ocr" and carries Tesseract's confidence. OCR reads pixels, not controls: small,
low-contrast or decorative text is misread or missed, a space can be lost ("Signin"), an icon without text is invisible,
and a screen with both light and dark areas is read as one, so read the other part alone with region. A dark theme is
handled by also reading the colour-inverted picture (invert, on auto).
screen.markswrites a screenshot with numbered boxes over what can be tapped and returns a list from number to element: label, text, description,resource_id, class, bounds and centre. On Android these are the enabled clickable, long-clickable, checkable and editable elements of the hierarchy; withsource:"ocr"(the default on the virtual iPhone) they are the lines of text found on the pixels. Over MCP the marked PNG is returned inline.xml_pathalso keeps the raw hierarchy.screen.taptaps a number from the lastscreen.marks(orscreen.tree) of that device. It first checks that the screen still has what the mark was made from (the same element in the hierarchy, or unchanged pixels for an OCR mark) and otherwise answersmark_staleand taps nothing;durationmakes it a long press.screen.treeis the hierarchy as short indented text for a model's context: only elements with text or a description, or that can be pressed or scroll, withid, bounds, state and#Nfor tappable ones, cut atmax_charswith a line that says how much is missing. Android only.screen.ocrgives every word and line with its box and confidence, and withtext(whole words) ortext_containsthe matches with their centre. It reads a device or animage.screen.diffsays what changed between two screens: two hierarchies (.xml: elements added, removed, changed in text, state or position) or two screenshots (.png: regions with box, size, share of changed pixels and position, and a note when only the status bar changed);text:truealso lists lines of text added and removed. Givedeviceinstead ofafterto compare with the screen now.screen.exportwrites one screen as a bundle: the screenshot at full resolution and a JSON file with the device, display (pixels and dp or points), insets, theme, locale, foreground app and every element with bounds in pixels and dp, state flags and itsscreen.marksnumber. Where there is no hierarchy the elements are OCR lines andhierarchy:"unavailable"says so.screen.export_batchdoes this for several devices into one directory with anindex.json, andscreen.diffaccepts two bundles. Screen export format is the field reference.screen.waitwaits fortextto appear, fortext_gone, or for the screen to bestable(no more thanthresholdof the pixels changing forstable_ms); a timeout answerstext_timeoutorscreen_unstablewith what it saw. The same waits are thewait_textandwait_stablesteps of atesting.runscenario.
On the virtual iPhone a selector with only text or text_contains (and index) in testing.find, testing.action
and the wait, assert, click and long_click steps is answered the same way, from the screenshot; the result says
source:"ocr" and pixel_based:true. fill and every other selector property still answer ui_hierarchy_unavailable.
# Look, then tap what the picture says is number 7
tomaccoctl call screen.marks '{"device":"emulator-5554","path":"/absolute/marked.png"}'
tomaccoctl call screen.tap '{"device":"emulator-5554","mark":7}'
# A compact tree to read, and a text check without a hierarchy
tomaccoctl call screen.tree '{"device":"emulator-5554","max_chars":3000}'
tomaccoctl call screen.ocr '{"device":"virtual-iphone","text_contains":"Settings"}'
# Wait for the screen to settle after an action, ignoring the clock
tomaccoctl call screen.wait '{"device":"virtual-iphone","until":"stable","stable_ms":1000,"ignore":[{"x":0,"y":0,"width":1206,"height":170}]}'
# What did that tap change?
tomaccoctl call screen.diff '{"before":"/absolute/before.xml","device":"emulator-5554"}'
In a scenario:
{"name":"Sign in","steps":[
{"action":"click","selector":{"text":"Sign in"}},
{"action":"wait_text","text":"Welcome","timeout":20},
{"action":"wait_stable","duration":1000,"timeout":10}]}
What ran: unit tests on recorded hierarchies, generated screenshots and fake Tesseract output, and one test that reads a
rendered screenshot with the installed Tesseract 5 (white and inverted, with a region). On an API 36 x86_64 emulator
(started without acceleration, so slow): screen.tree on a system "isn't responding" dialog gave three nodes and two
marks; screen.tap on the "Wait" mark verified the element and dismissed the dialog, and screen.diff against the
earlier hierarchy reported a different screen (the dialog's package to Settings). screen.ocr on a screenshot of
another such dialog read "Close app" and "Wait" but "System Ul isn't responding" for "System UI", in 1.1 s for two
passes. On that emulator android.screen itself timed out at 10 seconds (a screencap took 12.7 s), so screen.marks
with its picture, screen.wait and a pixel screen.diff between two different screens were not run live. Nothing
here ran on the virtual iPhone.
UI tests on the virtual iPhone#
Pass virtual-iphone as the device to testing.run, testing.record and testing.crashes while the virtual iPhone runs. The
guest tools report no UI hierarchy, so nothing can find an element by its properties there: testing.lint,
testing.accessibility, fill and a selector other than text or text_contains answer ui_hierarchy_unavailable, whose
details.testing lists what works. A selector with text or text_contains is read off the screenshot with OCR instead
(see Seeing the screen).
- Steps.
tapandswipetake pixels of the screenshot (ios.virtual.screenshotwithframe:"none") and go throughios.emulator.controlasclickanddrag; a contact lasts at most 2 seconds, and a swipe that stays put is a long press.texttypes on the USB keyboard into the focused field.keytakes a QEMU key name such asret,homefor the Home button, or an Android key code that has a counterpart (KEYCODE_ENTER,KEYCODE_DEL,KEYCODE_HOME, the arrows).launchopens an app through the guest service'sopen;stopandclearare not available.launchreturns once iOS accepted the request, and the app shows a few seconds later (about 7 s on the development host), so follow it with await_screenbefore tapping. - Screens instead of elements. On both platforms,
screenshotkeeps a PNG in the report directory,pausewaitsdurationmilliseconds,assert_screencompares the screen with abaselinePNG astesting.visual_diffdoes (threshold,tolerance) and fails withscreen_mismatch, andwait_screentakes screenshots until one matches ortimeoutpasses (screen_timeout). A failure writes the difference image beside the screenshot. Make the baseline withios.virtual.screenshot, or withtesting.visual_diffandupdate_baseline:trueafter you have looked at it.ignoreleaves out regions in pixels as intesting.visual_diff: on the phone, the status bar (the top 170 pixels of a 1206×2622 screen) and the Clock app's icon change with the time. - Crashes.
testing.crashesneedsprocess, the app's executable name or another report name prefix, and reads the guest's crash reports through itscrash_reportsoperation. Each report with an exception becomes an event like Android's:kindcrash,time(the report's capture time),package(the bundle identifier),process,pid,thread,exception(such asEXC_CRASH),signal,message(the abort message, the exception's subtype or the termination),terminationandstack, the crashed thread's frames. Reports that give no event are listed underunparsedwith the reason. The guest returns at most three reports per directory, socollection_complete:falsemeans some were left out, and an empty list never proves that nothing crashed.testing.rundoes not read crash reports after an iPhone scenario; its result says so incrashes_error. - Recording.
testing.recordwithvirtual-iphonenotes the screen's size atstart, then turns theios.emulator.controlinput sent to the running phone into steps:clickbecomes atap,dragaswipe, a streamed touch fromtouch_downtotouch_up(the desktop's phone window sends these) a tap, a long press or a swipe, andtext,keyandhometheir steps. A key combination and the side button are listed underskipped. Every tap is at coordinates. A gap of a second or more between two inputs becomes apausestep of that length, up to 10 s, so a replay waits where the person waited.
tomaccoctl call testing.record '{"device":"virtual-iphone","action":"start","name":"Open Settings"}'
# tap and type in the desktop's phone window, or with ios.emulator.control
tomaccoctl call testing.record '{"device":"virtual-iphone","action":"stop","path":"/absolute/open-settings.json"}'
tomaccoctl call testing.run '{"path":"/absolute/open-settings.json","directory":"/absolute/iphone-results","devices":["virtual-iphone"]}'
tomaccoctl call testing.crashes '{"device":"virtual-iphone","process":"MyApp","job_id":"JOB_ID"}'
What ran on a live virtual iPhone (iPhone 18 Pro, iOS 27): a scenario that presses Home, launches an app, waits for its screen,
taps a button and asserts the result against baselines taken live passed in 10 s, with threshold 0 and the status bar
ignored (changed fraction 0.0); the same assertion against the screen before the tap failed with screen_mismatch (0.41 % of
the pixels changed) and wrote its difference image. testing.record turned a tap on an icon, a 9 s wait and Home into tap,
pause and key, and the replay with a wait_screen for the Home screen passed. testing.crashes read the phone's three
fault reports and listed them as not-a-crash; no app had crashed, so no crash event was seen live. On Android the screen
steps are unit-tested only.
What works where, kept equal to the code by a unit test (capabilities returns the same as testing).
| Feature | Android | Virtual iPhone | iPhone (USB) |
|---|---|---|---|
testing.find, testing.action (selectors) | Yes: UI hierarchy | No: the guest reports no UI hierarchy | No |
testing.run: tap, swipe, text, key | Yes | Yes: through ios.emulator.control | No |
testing.run: wait, assert, click, long_click, fill | Yes | No: no UI hierarchy | No |
testing.run: screenshot, assert_screen, wait_screen, pause | Yes | Yes: from ios.virtual.screenshot | No |
testing.run: launch | Yes | Yes: the guest's open | No |
testing.run: stop, clear | Yes | No | No |
Crash check after a testing.run scenario | Yes | No: call testing.crashes | No |
testing.visual_diff | Yes | Yes: PNG from ios.virtual.screenshot | Yes: PNG from device.capture |
testing.crashes | Yes: logcat | Yes: crash reports, by process | No: use ios.crash_reports |
testing.record | Yes: taps named by element | Yes: taps at coordinates | No |
testing.lint | Yes | No: no UI hierarchy | No |
testing.accessibility | Yes | No: no UI hierarchy | No |
testing.find, testing.action and the wait, assert, click, long_click steps by text (OCR) | No: selectors read the UI hierarchy; screen.ocr reads pixels | Yes: pixel-based: text and text_contains only, read with tesseract | No |
screen.marks, screen.tap | Yes: elements from the UI hierarchy | Yes: lines of text read with tesseract | No |
screen.tree | Yes | No: no UI hierarchy | No |
screen.ocr | Yes: tesseract | Yes: tesseract | No: give an image |
screen.diff | Yes: UI hierarchy or pixels | Yes: pixels, and text with OCR | No: give two images |
screen.wait, the wait_text and wait_stable steps | Yes: text from the hierarchy, or OCR | Yes: text by OCR; stable by pixels | No |
testing.setup (Unicode text) | Yes | No: text types US or German keys | No |
What has been run: testing.visual_diff, testing.accessibility (on the Settings app), testing.crashes (with a crash from
am crash) and the crash list of testing.run on an API 36 x86_64 emulator, and testing.results on a testing.run report. ANRs,
native crashes and Gradle's own result files have been read only from sample text in the unit tests.
On an API 36 x86_64 emulator with the Settings app: testing.record with android.input, testing.action and runtime.input
(touch, key, swipe and rotation sent through a display session from the command line, not by hand in the desktop), the recorded
scenario passing testing.lint and testing.run, and a directory of two scenarios run with shard and retries. A pass after a
failure (flaky) has been seen only with a stand-in adb in the tests.
profile.capture records Android Perfetto traces. Configure the official trace_processor, then use profile.analyze and profile.compare. Metrics that are
not available are null, not zero. A count of frames with jank tags is not the same as visible drops.
App performance#
These operations read Android's own counters, without Perfetto. Each returns a report with flat metrics; give report an
absolute path to save it as JSON.
profile.startup launches an app runs times (5 by default) with am start -W and reports TotalTime and WaitTime in
milliseconds: every run, and their count, min, max, mean, median and p90 (nearest rank). mode cold stops the app before each
launch, warm keeps the process and recreates the activity (--activity-clear-task), and hot brings the running activity back
from the home screen; warm and hot start the app once first, unmeasured. Each run names the launch state Android reported, and
mismatched_runs counts the runs whose state differs from mode. The metrics are startup_<mode>_median_ms, _p90_ms, _min_ms,
_max_ms and _mean_ms. activity picks another activity than the launcher's.
tomaccoctl call profile.startup '{"device":"emulator-5554","bundle_id":"com.android.settings","mode":"cold","runs":5}'
On an API 36 x86_64 emulator, five runs each of the Settings app reported COLD, WARM and HOT for every run of the matching mode.
Startup on an iPhone. profile.startup also takes the virtual iPhone (device virtual-iphone) and a USB-connected
iPhone (platform ios with its UDID). Neither has a launch timer like am start -W, so the screen is timed. Before each
launch the app is ended (cold) or sent to the background (hot), and the screen is left to keep still; that picture is the
baseline. Then the app is opened, and the screen is read until it has kept still for settle_ms (2000 by default, 200 to
10000). Each picture is reduced to a 32 × 64 grid of grey cells; a cell has changed when its grey moved by more than 12 of 255.
first_frame_ms: from the open request to the first picture that differs from the baseline in at least 5 % of the cells and stays different. It is the value of the usual metrics,startup_<mode>_median_ms,_p90_ms,_min_ms,_max_msand_mean_ms. On the virtual iPhone's Home screen the ticking Clock icon and the status bar change under 0.5 % of the cells; an app's window changes over 90 %.launched_ms: until the open request returned. On the virtual iPhone that is when FrontBoard's completion named the app's process, on an iPhone whengo-ios launchprinted its pid. Metricsstartup_<mode>_launched_median_msand_p90_ms.settled_ms: until the last change of at least 0.5 % of the cells before the screen kept still. Metricsstartup_<mode>_settled_median_msand_p90_ms; null, and so failing a budget, when the screen never kept still.- On a USB iPhone also
first_log_ms: when the app's first syslog line after the request arrived, withstartup_<mode>_first_log_median_msand_p90_ms.
Every measurement starts with one unmeasured launch, which shows that the app opens and names its process. Each run's
launch_state is cold when the launch started a new process and hot when it found the one from before, by process ID;
mismatched_runs counts the runs whose state differs from mode, and a run without a process ID names none. warm is
refused: an iPhone app cannot be made to recreate its scene from outside, and Home and reopen is what Android calls hot. A
cold launch here still finds the system's caches warm, so it is what Apple calls a warm launch; launches after a reboot are not
measured. resolution_ms is the median time between two looks at the screen. The report has device_kind
(virtual-iphone or iphone) and a note on what its times mean, and profile.compare and profile.budget read its
metrics as they read Android's. Compare reports of the same kind of device only.
tomaccoctl call profile.startup '{"device":"virtual-iphone","bundle_id":"dev.example.myapp","mode":"hot","runs":5}'
tomaccoctl call profile.startup '{"platform":"ios","device":"00008110-0011223344556677","bundle_id":"dev.example.myapp"}'
On the virtual iPhone, cold calls the guest's restart_ui, which restarts iOS's interface; no guest operation ends one
app, and on the test phone the app's process survived the restart (the same process ID before and after, for two apps), so
every cold run reported launch_state hot and mismatched_runs counted them. Installing the app again
(ios.virtual.deploy with force, 26.9 s) did give a new process. Every cold run takes the seconds the restart takes, and the
lock screen it brings back is left first, as for a scenario, once the guest can say whether it is locked. hot presses Home and waits until the app is no longer in front. Frames come from ios.emulator.control's
file transport, from the frame mailbox; a frame number the last read already had is the same picture and is not read again.
The guest runs on an emulated processor with a software renderer, so its launches take far longer than an iPhone's and say
nothing about one.
On a USB iPhone, cold runs go-ios kill BUNDLE --udid=UDID and hot opens Settings with
go-ios launch com.apple.Preferences --udid=UDID, as go-ios has no Home button. The launch is go-ios launch BUNDLE --udid=UDID with the installed bundle ID (xtool's prefix included), the screen is go-ios's MJPEG stream from
go-ios screenshot --stream --port=PORT --udid=UDID, read over HTTP/1.0 and scaled to the grid by ffmpeg, and the log is
go-ios syslog --udid=UDID. These times are coarse: a change is seen when the next streamed picture arrives, plus the stream's
own delay, and every time includes go-ios connecting to the phone for the launch request. The phone needs the developer image,
and on iOS 17 and newer a tunnel (ios.prepare).
On the virtual iPhone, with the example app examples/hello-virtual-iphone deployed, three hot runs took a median 1329 ms
to the first frame (1284 to 1950 ms), 460 ms until the open request returned with the app's process (423 to 523 ms) and 1604 ms
until the screen was still (1526 to 2513 ms); mismatched_runs 0, baseline_still true in every run, resolution_ms 0.3. Two
cold runs of the same app took a median 1363 ms to the first frame, 363 ms to launched and 2468 ms to settled, but both
reported launch_state hot (mismatched_runs 2), so they measure a launch of a running app; the research app gave 1287 and
1599 ms to the first frame, also with mismatched_runs 2. A launch of an app that was not running was not measured on a live
phone, and neither was a USB iPhone. The change detection is also checked against grids of real virtual-iPhone screenshots
(its Home screen, the same screen with an icon pressed and the clock moved, the research app's window), the runs with a
recorded launch sequence, and the iPhone route with stand-ins for go-ios and ffmpeg.
profile.frames resets a running app's frame statistics (dumpsys gfxinfo reset), waits seconds (5 by default) or runs a
testing.run scenario given as scenario, and reads them again. The metrics are frames_total, janky_frames, jank_percent,
slow_frames (17 ms or more) and frozen_frames (700 ms or more), both counted from the frame time histogram, and
frame_p50_ms, frame_p90_ms, frame_p95_ms and frame_p99_ms as dumpsys gives them for the app's main process. processes
keeps each process's counters, such as missed_vsync and slow_ui_thread. An app that drew nothing in that time has null metrics,
not zero; an app that is not running answers app_not_running, and a failed scenario scenario_failed with its steps.
tomaccoctl call profile.frames '{"device":"emulator-5554","bundle_id":"com.android.settings","scenario":"/absolute/scroll.json"}'
On the same emulator, a scenario of three swipes in Settings gave 94 frames, 2 of them janky.
profile.memory reads a running app's dumpsys meminfo. Each process in processes has the App Summary in KB (PSS and RSS of the
Java heap, native heap, code, stack, graphics and the rest), total_pss, total_rss and total_swap, the size, allocated and free
parts of the native and Dalvik heaps, and object counts such as activities and views. The metrics add the processes up in MB:
memory_pss_mb, memory_rss_mb, java_heap_pss_mb, native_heap_pss_mb, code_pss_mb, stack_pss_mb and graphics_pss_mb,
and the activities and views counts. With heap_dump, it also writes an HPROF heap dump of the main process there, through a
file on the device that is removed afterwards. That needs a debuggable app or a debuggable system image, otherwise it answers
heap_dump_failed; Android Studio opens the file, other tools after hprof-conv from the platform tools.
tomaccoctl call profile.memory '{"device":"emulator-5554","bundle_id":"com.android.settings","heap_dump":"/absolute/settings.hprof"}'
On the same emulator, Settings used 54 MB PSS, and its heap dump was a 47 MB HPROF file.
profile.usage measures a running app over seconds (10 by default). CPU is the user and system time of the app's processes in
/proc before and after: cpu_ms, cpu_percent of one core (above 100 when it used more than one) and cpu_device_percent of all
cores, also per process in processes. Battery is the change of the app's UID in batterystats' estimated power use, in mAh, in total
(power_mah) and per part such as cpu or sensors. That estimate comes from the device's power profile, which on an emulator is not
a real phone's; battery also says which UID it is (system apps share one), whether a charger is connected, and the level. Nothing on
the device is reset.
tomaccoctl call profile.usage '{"device":"emulator-5554","bundle_id":"com.android.settings","seconds":5}'
On the same emulator, Settings scrolled for five seconds used 1790 ms of CPU, 35 % of one core.
Budgets. A budget gives metrics a maximum, a minimum or both:
{"startup_cold_median_ms": {"maximum": 800}, "jank_percent": {"maximum": 5}, "memory_pss_mb": {"maximum": 200}}
profile.budget checks saved reports of one app against it, given as a file (budget) or inline (budgets). Each result has the
metric, its value and limits, passed, and the report it came from; a metric no report measured fails as measurement_unavailable.
tomaccoctl call profile.budget '{"reports":["/absolute/startup.json","/absolute/memory.json"],"budget":"/absolute/budget.json"}'
In a testing.run scenario, a measure step records metrics of the app named by bundle_id: measure is startup (with mode,
runs and activity), frames_reset and later frames, memory, or usage (with seconds). A step's own budget is checked
against that step's metrics, and a scenario's budget, or the run's budget file or inline budgets, against all the metrics of the
scenario. A metric outside fails the scenario as budget_exceeded; each result in report.json carries its metrics and budget.
{"name": "Settings performance",
"budget": {"startup_cold_median_ms": {"maximum": 2000}, "jank_percent": {"maximum": 20}},
"steps": [
{"action": "measure", "measure": "startup", "bundle_id": "com.android.settings", "runs": 3},
{"action": "measure", "measure": "frames_reset", "bundle_id": "com.android.settings"},
{"action": "swipe", "x": 500, "y": 1800, "end_x": 500, "end_y": 400},
{"action": "measure", "measure": "frames", "bundle_id": "com.android.settings"}]}
On the same emulator, a testing.run of such a scenario with a memory step passed its budget, and profile.budget failed a saved
startup report of 303 ms against a maximum of 300.
Baselines. Save a report as the baseline, measure again later, and profile.compare lists under changes every metric of either
report with its baseline and current value, delta and delta_percent. Both reports must be of the same app and kind. budgets
are optional here: a maximum or an increase_percent over the baseline fails the comparison, and without budgets passed stays true.
tomaccoctl call profile.compare '{"baseline":"/absolute/startup-baseline.json","current":"/absolute/startup.json","budgets":{"startup_cold_median_ms":{"increase_percent":25}}}'
On the same emulator, two cold startup reports of three runs each differed by 226 ms in their median (303 and 529 ms), which failed an
increase_percent of 25: an emulator's launch times vary that much between runs, so compare medians of more runs on a quiet host.
Macrobenchmark and Baseline Profiles (Android). project.inspect reads the build files for these and lists them under
performance, without running Gradle: benchmark_modules (a com.android.test module that depends on
androidx.benchmark:benchmark-macro, or applies the androidx.baselineprofile plugin, with its target app, the Gradle task
Tomacco would run, and its benchmark and generator classes), baseline_profile_modules (app modules that apply the plugin, with
the generate task) and profiles (the profile files found, with rule counts). A build with none of them has no performance.
profile.macrobenchmark runs the benchmark module on a device as a job: connected<Variant>AndroidTest of that module
(connectedBenchmarkReleaseAndroidTest when the plugin is applied, connectedBenchmarkAndroidTest for a benchmark build type,
connectedCheck otherwise; variant overrides), with ANDROID_SERIAL set to device. class limits the run to a test class
or Class#method. On an emulator it adds androidx.benchmark.suppressErrors=EMULATOR, which Macrobenchmark needs there
(allow_emulator), and an emulator's numbers are not a phone's. The job's last step is profile.benchmark_results, so the job ends
with a performance report. The benchmark JSON, the Perfetto trace of every iteration and the JUnit XML are the job's artifacts;
testing.results reads the XML.
profile.benchmark_results reads *benchmarkData.json from build/outputs/connected_android_test_additional_output into a report
of kind macrobenchmark that profile.compare and profile.budget read like the others. Metrics are
<test>_<metric>_<statistic>: minimum, median and maximum for timeToInitialDisplayMs
(startupCompilationNone_time_to_initial_display_ms_median), p50, p90, p95 and p99 for sampled metrics such as
frameDurationCpuMs, and a test name two classes share is prefixed with its class. benchmarks keeps each run's values and its
traces. With analyze, the first trace of each benchmark is summarised by profile.analyze (CPU time, frames, jank of the app's
processes) under trace_analysis. job_id or since leaves out older files.
profile.baseline_profile runs generate<Variant>BaselineProfile of the app module that applies the plugin, on a device, and ends
with profile.baseline_rules. The plugin writes src/<variant>/generated/baselineProfiles/baseline-prof.txt and startup-prof.txt,
which AGP packages into the release build. profile.baseline_rules lists those files and src/*/baseline-prof.txt with their
rules counted: classes, methods, and the H hot, S startup and P post-startup flags. A generated file whose content did not
change can keep its old modification time, so fresh is false for it. profile.baseline_install copies the newest generated profile
to src/main/baseline-prof.txt (or a destination inside the module), where AGP packages it without the plugin; a different file
there is kept as .bak and only replaced with overwrite.
profile.baseline_compare times an installed release build with and without its profile in one call: cold starts (runs, 5 by
default) after cmd package compile -f -m verify, which ignores the profile, then, after the app's androidx.profileinstaller
installed its profile (the INSTALL_PROFILE broadcast) and cmd package compile -f -m speed-profile, cold starts again. The
report has both profile.startup reports, the compiler filter Android reported for each, comparison, and the metrics
startup_cold_<statistic>_ms_without_profile and _with_profile, improvement_ms and improvement_percent. An app that does not
install a profile answers profile_install_failed. The app is left compiled with speed-profile.
tomaccoctl call project.create '{"path":"/absolute/PerfApp","kind":"compose","name":"PerfApp","organization":"dev.example","performance":true}'
tomaccoctl call profile.baseline_profile '{"path":"/absolute/PerfApp","device":"emulator-5554"}'
tomaccoctl call profile.macrobenchmark '{"path":"/absolute/PerfApp","device":"emulator-5554"}'
tomaccoctl call profile.benchmark_results '{"path":"/absolute/PerfApp","analyze":true,"report":"/absolute/macro.json"}'
tomaccoctl call profile.baseline_compare '{"device":"emulator-5554","bundle_id":"dev.example.perfapp","runs":5}'
project.create with kind compose and performance:true adds a :baselineprofile module, the structure Android Studio's
template uses: a com.android.test module with a StartupBenchmarks class (cold start without and with the profile, 5
iterations) and a BaselineProfileGenerator that starts the app, and the plugin and androidx.profileinstaller in the app. It
uses AGP 9.1.1, androidx.benchmark 1.5.0, the androidx.baselineprofile plugin 1.5.0, profileinstaller 1.4.1 and UiAutomator
2.4.0.
What ran, on an API 36 x86_64 google_apis emulator started headless with SwiftShader, from a Compose app created with
performance:true (and a hand-built twin of it before the starter existed):
profile.baseline_profilegenerated a profile of 10,212 rules inapp/src/release/generated/baselineProfiles/(7,848 methods, 2,365 classes), about 70 s with a warm Gradle cache.profile.macrobenchmarkran the three tests (the generator is skipped in the benchmark variant) and wrote the report; the job listed 10 Perfetto traces, the JSON and the XML as artifacts. Medians oftimeToInitialDisplayMsover 5 iterations: 374.3 ms with the profile and 412.7 ms without in the starter, 380.6 and 389.0 ms in the twin. The two differ little because the emulator's variation is as large.profile.benchmark_resultswithanalyzesummarised the first trace of each benchmark throughtrace_processor.profile.budgetfailed a median of 380.6 ms against a maximum of 300, andprofile.compareread two such reports.profile.baseline_compareon the twin'sbenchmarkReleasebuild, 5 cold starts each: median 582 ms (compiler filterverify) and 469 ms (speed-profile), an improvement of 113 ms (19.4 %); the fastest runs were 346 and 405 ms, so on an emulator the median of more runs is the number to trust. The profile install answered result 1. The whole call took 13 s.profile.baseline_installcopied the generated file tosrc/main/baseline-prof.txtand a second call changed nothing.
The metric parsing is also tested against that run's recorded benchmarkData.json. The p50 to p99 names come from the
AndroidX format and are tested with a written example; no frame benchmark was run. A physical phone was not used.
Release#
release.keystore, release.sign, release.verify and release.version handle Android artifacts. Passwords come from owner-only files, never a string in a call.
release.version sets one version name and build number in every version file it finds in the project: pubspec.yaml,
Expo's app.json (expo.version, expo.android.versionCode, expo.ios.buildNumber), the package.json of a React
Native or Expo app, app/build.gradle(.kts) or android/app/build.gradle(.kts), and the Info.plist that infoPath in
xtool.yml names. A Gradle versionCode = libs.versions.appCode.get().toInt() changes that entry of
gradle/libs.versions.toml, and providers.gradleProperty("appVersion") that key of gradle.properties; a Flutter
module's flutter.versionCode follows pubspec.yaml and is listed under skipped. Any other computed value is refused
with version_ambiguous before a file changes. Instead of version and build_number, bump (major, minor, patch
or build) raises the current version and sets the build number to the highest current one plus 1. dry_run:true returns
the same changes (file, field, from, to) without writing; otherwise each changed file gets a backup, listed in files.
JSON files are written back with two-space indentation.
tomaccoctl call release.version '{"path":"/absolute/app","bump":"patch","dry_run":true}'
release.notes turns one section of a Markdown changelog into Google Play's "What's new" text for each language and checks
it against Play's limit of 500 characters. section names the heading (2.0.0 matches ## [2.0.0] - 2026-11-01; empty takes
the first section), changelog is the file for language (default en-US), and translations adds other languages' changelogs
with the same section. Bullets become •, bold, code and link markup is removed. A text over the limit is a too_long error
and no file is written for any language; truncate:true drops whole lines from its end instead and reports truncated. The
files go under directory in the layout the upload tool reads: fastlane (LANGUAGE/changelogs/default.txt, or
BUILD.txt with build_number), play-publisher (release-notes/LANGUAGE/default.txt) or whatsnew (whatsnew-LANGUAGE).
The result's release_notes is the release_notes argument of play.prepare, which stages them with the bundle.
tomaccoctl call release.notes '{"changelog":"/absolute/app/CHANGELOG.md","section":"2.0.0","directory":"/absolute/app/fastlane/metadata/android","translations":[{"language":"de-DE","changelog":"/absolute/app/CHANGELOG.de.md"}]}'
release.signing_health reads a keystore with keytool -list -v and reports each key's owner, validity (valid_until,
days_left), key algorithm and size, signature algorithm and SHA-256 fingerprint. Findings: expired (an error),
expires_soon within warn_days (default 365), play_minimum_validity for a key that ends before 22 October 2033, which
Google Play requires of app signing keys, weak_key under 2048 bits (an error), dsa_key and weak_signature (MD5 or
SHA-1). Pass the upload and app signing certificate fingerprints from Play Console's app integrity page as
upload_certificate_sha256 and app_signing_certificate_sha256: each key gets a play_role (upload, app_signing or
none), no match is the error not_play_key, and Play's app signing key in the same keystore as a different upload key is
the warning app_signing_key_local. artifact checks that an APK or AAB is signed with one of the keys. Nothing is sent
to Play; passed is false when a finding is an error.
tomaccoctl call release.signing_health '{"keystore":"/absolute/upload.p12","password_file":"/absolute/upload.password","upload_certificate_sha256":"AB:CD:..."}'
release.checklist checks an Android app's release build before an upload, from its build files and manifest without
running Gradle. Each entry of checks has a status (pass, fail or unknown), a severity and its evidence:
target_sdk against the level Google Play requires (required_target_sdk, by default 36, Play's requirement for new apps
and updates since 31 August 2026), version_code above previous_version_code or a baseline package's, debuggable off in
the release build type and the manifest, minify on (a warning), signing_config set and not the debug key, and cleartext:
usesCleartextTraffic or a network security config that permits cleartext HTTP fails unless allow_cleartext. artifact
reads targetSdk and versionCode from the built APK or AAB, for builds that compute them. passed is false when an
error-severity check fails.
tomaccoctl call release.checklist '{"path":"/absolute/app","previous_version_code":41}'
play.prepare creates an edit, uploads the AAB, sets the track and validates, without committing. Inspect that before an explicitly authorized play.commit;
play.abort discards it. Live store submission has not been qualified, on Play or on the App Store.
Updates#
update.check reports current, latest, notes_url, update_available and a state: up_to_date, update_available, no_release, no_package, offline, error or not_checked.
Without arguments it follows updates.mode, using the network at most once a day and never when off. Pass manual only when the user asked for a check.
It asks for GitHub's latest release, or, with updates.channel set to prerelease, for the newest release including pre-releases; channel in the reply says which. A check from the other channel
counts as not_checked.
tomaccoctl call update.status '{}'
tomaccoctl call update.check '{"manual":true}'
tomaccoctl call update.apply '{}'
tomaccoctl call update.rollback '{}'
tomaccoctl call settings.configure '{"updates":{"mode":"off"}}'
update.apply starts a durable job (download, signature check, install, health check, automatic rollback on failure). It is refused, with the reason in the error, while a job, a device display
session or the virtual iOS device runs, for a source checkout, a system package or an unwritable installation, without Python, and with updates.mode off unless manual says the user asked.
update.rollback restores the version before the last update. settings.configure takes updates.mode (off, notify, auto), updates.channel (stable, the default, or prerelease) and updates.token_file, an owner-only file. TOMACCO_UPDATES=off
turns all of it off. After an update, restart tomaccoctl mcp to use the new version; the running server keeps working.
From a terminal, tomaccoctl update runs the manual check and, when a release applies, update.apply and its job to the end.
tomaccoctl verify FILE [--json] checks a downloaded release file against its .sha256 and its .sig with the same release keys, without installing anything.
Web layouts#
The layout lab is a separate Chromium surface, not a device. Start a session with tomacco --url URL --preview-session NAME, and discover it with preview.sessions.
preview.configureselects a profile, orientation, fold, chrome, safe-area metadata, guides and zoom.preview.configurealso sets what the page sees forprefers-color-scheme(color_scheme:system,light,dark) andprefers-reduced-motion(reduced_motion:system,reduce,no-preference), and throttles or cuts its network:network:{preset:"slow-3g"},{preset:"offline"}, orlatency_ms,download_kbpsandupload_kbps;{preset:"none"}turns throttling off. The settings belong to the session, last across reloads and are reported bypreview.state. They go through Chromium's DevTools protocol, so they cover the page's requests but not a service worker's, and not WebSocket traffic.preview.readywaits for the document, fonts and Flutter's first frame. Await_forexpression can wait for your own readiness.preview.inspect,preview.click,preview.fill,preview.evaluate,preview.consoleandpreview.captureinteract with the page.preview.errorsreports the current page load as structured results:console_errors,exceptions(uncaught errors and unhandled rejections, with stack),failed_requests(fetch, XMLHttpRequest, elements, style sheet resources, fonts and the document; status 0 is a network error) andclean.reload:truereloads and waits first, so the report covers one load.preview.consolekeeps the raw console. Requests from workers and frames are not recorded.preview.matrixcaptures several profiles and orientations and restores the session. Besides one PNG per capture andmatrix.json, it writescontact-sheet.png: every capture side by side at one scale, labelled with the model and its orientation.contact_sheet:falseleaves it out.preview.auditreports overflow, missing names and small targets. It is not an accessibility certificate or Safari emulation. Each finding has aruleand aselectorthat matches that element alone.document-overflowis a page wider than the viewport andhorizontal-overflowan element that sticks out;safe-area-textis text drawn where the profile's safe area ends (camera cutout, system bars), with itsedges, while safe-area metadata is on;target-size-platformis a control under 44 points on iOS and generic profiles or 48 dp on Android, with links in running text exempt;target-size-24is WCAG 2.2's 24-pixel minimum.catalog.export,catalog.importandcatalog.removemanage custom profiles, which are data only.android.calibratereads a device's real size and density.
The desktop guide has the details.
Research operations#
These lower-level ios.emulator.* operations are for research into the guest and the boot chain, not for everyday use. tomaccoctl schema has their arguments.
They share an admission lock: identical active requests reuse their job, and conflicting ones answer ios_emulator_busy. Do not retry with another state
directory to get around that. A successful process is never a qualification of an app runtime; read the report's own flags.
| Operations | What they are for | Details |
|---|---|---|
prepare_userland, build_gpu_plugin, prepare_restore, restore, usb_bridge, prepare_cryptexes, build_guest_tools, prepare_guest_tools, paravirtual | The steps of the virtual device's setup chain | How it works |
control, guest, guest_result, report | Input, frames and the guest channel on a running guest, and a job's receipt | Usage |
inspect, probe, exec | The separate restore-shell engine: run own programs in a real iOS restore environment | Research engines |
prepare_research, research | Full-system research images on that engine | Managed runs |
prepare_pmp, inspect_pmp, pmp, memory | Run and inspect PMP controller firmware in an isolated machine | PMP workflow |
tools/build_ios_native_example.py builds C and Embedded Swift command-line examples for that engine. Use desktop.open with tab:"ios-research" to show
the user its dialog.