loupio
v0.11.1
Published
See your whole app at once: every screen and journey, signed out and signed in, mapped automatically from the iOS Simulator (or a local web app).
Maintainers
Readme
Loupio
See your whole app at once. Loupio taps through your iOS app on the Simulator (or your local web app), screenshots every screen, and lays out every journey on one canvas, signed out and signed in. No more tapping through a test phone, screenshotting, and arranging it all in Miro or Figma.
The CLI is loupio (visual-plan still works as an alias). Config stays in visual-plan.config.json and ~/.visual-plan/ for now.
Self-hosted, repo-agnostic visual plan generator: a file/diff diagram, a
colorized diff, and real Playwright screenshots of your local dev server,
bundled into one self-contained report.html. Optionally push it to your own
Railway-hosted viewer for a shareable URL.
Built as a safe alternative to third-party "visual plan" tools whose hosted
mode ships your source code and screenshots to their servers. Here, screenshot
capture only ever targets localhost/127.0.0.1 (enforced, not just
documented), and the only thing that ever leaves your machine is the finished
HTML report you explicitly push — to infrastructure you own.
Install
npx loupio --helpNeeds Node 20+. iOS mapping needs Xcode with an iOS Simulator runtime. Web
mode (screenshots of a localhost dev server) also needs
npx playwright install chromium once.
Once a day the CLI asks the server for the latest version, sending only its
own version number and platform (no id of any kind). Turn it off with
VISUAL_PLAN_NO_UPDATE_CHECK=1; it is always off when CI is set.
Developing visual-plan itself
npm install && npm --prefix viewer install
npm run build:viewer && npm run build
scripts/e2e-fixture.sh # end-to-end crawl of ios/VPFixture, both statesDevelopment uses Node 22 (.nvmrc). CI runs on a self-hosted runner on the
maintainer's Mac (scripts/setup-runner.sh; GitHub-hosted Macs bill at 10x).
Releases publish from CI on a v* tag via npm Trusted Publishing (no token;
the publish job runs on a GitHub-hosted Linux runner).
CI runs the fast checks on every push; the full end-to-end crawl runs only by
hand (npm run e2e, or "Run workflow" on GitHub), because Simulators don't
finish booting under the runner's background service.
Usage
From any repo:
visual-plan build # writes .visual-plan/report.html and canvas.html
visual-plan push # pushes it, prints back a shareable URLScreenshots are opt-in — drop a visual-plan.config.json in the repo root:
{ "devServerUrl": "http://localhost:3000", "paths": ["/", "/dashboard"] }iOS apps (signed out and signed in)
visual-plan crawls an iOS app on the Simulator: it taps through every reachable screen, screenshots each distinct one, and records which tap led where. It can map the app twice, once signed out and once signed in, and shows both side by side on the canvas. It never sees or types your password.
Needs Xcode (with an iOS Simulator runtime) and Node 20+.
npx loupio init # finds your Xcode project, builds the app, creates the signed-out Simulator
npx loupio ios login # opens that Simulator: sign in by hand, then press Enter
npx loupio build --open # crawls both states, opens .visual-plan/canvas.htmlHow the states work:
initbuilds your app, keeps a copy of the build in~/.visual-plan/apps/, and creates a Simulator namedvisual-plan · <bundle id> · signed-outwith the app installed and never launched. Every signed-out crawl runs on a brand-new Simulator with a fresh install, deleted afterwards. (Not asimctl clone: on current Xcode a clone keeps using the original's app data folder, so state would leak between them.)ios logincreatesvisual-plan · <bundle id> · signed-inwith a fresh install and opens it so you can sign in however your app signs in (Apple, Google, email, codes sent to your phone). The session stays saved on that Simulator. Signed-in crawls run on it directly, not on a copy, because apps that rotate refresh tokens would see an old token reused and revoke the session. If a signed-in crawl comes back looking signed out, runios loginagain. Or skipios loginaltogether: with alaunchEnvironmentfor signed-in (below) and no saved signed-in Simulator, each signed-in crawl runs on a fresh Simulator like signed-out and the app signs itself in (see "Signing in automatically").- Use a test account. The crawler taps real buttons as that account. It
looks but doesn't commit: it never taps a switch, or anything matching
delete, remove, erase, reset, deactivate, log out, sign out, purchase, buy,
pay, subscribe, checkout, send, submit, confirm, redeem, post, block, leave,
transfer, cancel subscription or sold at the start of a word, including in
an unlabelled row's inner text (the
skipsetting adds more patterns to that list; it can't remove any). A label of more than six words is a description, so only its first four words are checked. If a tap flips a control (a genre chip, a checkbox: its selected state or value changes while the screen stays the same), the crawler taps it again to undo it and leaves the rest of that list alone; it does the same after two rows of a list change nothing visible. If the control disappears instead (a dismissed row), it warns and never taps a control with that label again. Every tap, skip and undo is recorded in.visual-plan/ios-crawl/<state>/crawl-plan.md.
Signing in automatically
ios login needs you at the keyboard. If your app can sign itself in from a
value it is launched with, signed-in crawls run unattended: no ios login,
and they can run in CI (and, later, hosted crawls). Use a test account;
the crawler taps real buttons as it.
The contract:
launchEnvironmentinvisual-plan.config.jsonmaps an app variable to a host variable (seelaunchEnvironmentunder the config keys below). The values come from your shell or.visual-plan/secrets.env, never from the config, which is committed.- Loupio always launches the app with
LOUPIO_CRAWL=1andLOUPIO_STATE=signed-outorsigned-in(or a data state's name, see "Test data per state").
npx loupio kit adds Loupio.swift to your project (add it to your app
target). It gives you Loupio.value("NAME") (a launch value),
Loupio.state, Loupio.isCrawling and .loupioScreen("Name") (screen keys,
below). All of it is #if DEBUG inside: in a Release build value returns
nil and isCrawling is false. Re-run kit after upgrading Loupio to update
the file.
Your own backend (session token, e.g. a custom JWT). Add a script on
your backend that signs a session token for the test user and writes it to
.visual-plan/secrets.env:
APP_TEST_TOKEN=eyJhbGciOi... # from e.g. `node scripts/mint-test-token.js [email protected]`Map it with "launchEnvironment": { "signed-in": { "TEST_SESSION_TOKEN": "APP_TEST_TOKEN" } },
and at launch store it wherever the app normally keeps its session:
@main
struct MyApp: App {
init() {
#if DEBUG
// Loupio: start the signed-in crawl already signed in as the test user.
if Loupio.state == "signed-in", let token = Loupio.value("TEST_SESSION_TOKEN") {
// Placeholder: replace with your own session code. Save the token where a real
// sign-in saves it (Keychain), then mark the app signed in.
SessionStore.shared.adopt(token: token)
}
#endif
}
var body: some Scene { WindowGroup { RootView() } }
}Supabase Auth. Put TEST_EMAIL and TEST_PASSWORD in launchEnvironment
and sign in before the first screen decides what to show:
.task {
#if DEBUG
if Loupio.state == "signed-in",
let email = Loupio.value("TEST_EMAIL"), let password = Loupio.value("TEST_PASSWORD") {
try? await supabase.auth.signIn(email: email, password: password)
}
#endif
}Firebase Auth. The same, with
try? await Auth.auth().signIn(withEmail: email, password: password).
Skip onboarding. Any @AppStorage / UserDefaults bool can be preset
with launchArguments, no code needed:
"launchArguments": { "signed-in": ["-didCompleteOnboarding", "YES"] }.
Turn analytics off while crawled, so crawls don't show up as users:
#if DEBUG
if Loupio.isCrawling { analytics.optOut() }
#endifSafety:
- Test account only, never a real person's.
- Release builds never contain the hook: keep every call inside
#if DEBUG. - To cut access, rotate or revoke the token (or the test account's password).
- Never commit
.visual-plan/secrets.env(.visual-plan/ignores itself in git; keep it that way).
Test data per state
Some screens only exist for some data: a past event's page, a festival, an
empty inbox. Add a data state for each, as an object in states:
"states": [
"signed-out", "signed-in",
{ "name": "past-event", "basedOn": "signed-in",
"launchArguments": ["-seedSet", "past-event"],
"launchEnvironment": { "FIXTURE_URL": "MY_FIXTURE_URL" } }
]A data state is crawled like its base (signed-in or signed-out) on a fresh
Simulator, with the base's launch arguments and environment plus its own, and
shows up as its own state in the map and the Library (build --state
past-event crawls just it). Names are a-z, 0-9 and - (30 characters at
most); up to 6. A data state based on signed-in needs signed-in to sign in
by launchEnvironment (above): a fresh Simulator can't copy a saved sign-in.
Listing only data states keeps signed-out and signed-in as the default.
The app gets LOUPIO_STATE=past-event, so a DEBUG hook can load fixtures
before the first screen shows:
@main
struct MyApp: App {
init() {
#if DEBUG
// Loupio: the past-event state shows the app with a past event in it.
if Loupio.state == "past-event" {
// Placeholder: replace with your own data layer. Load a bundled JSON fixture,
// or point the app at seed data, e.g. Loupio.value("FIXTURE_URL").
DataStore.shared.loadFixture(named: "past-event")
}
#endif
}
var body: some Scene { WindowGroup { RootView() } }
}Pointing it at seed data: keep fixtures as JSON files in a DEBUG-only folder of
the app target, or seed a test account on your backend (a script, as for the
session token above) and pass its id or URL through launchEnvironment. The
values come from your shell or .visual-plan/secrets.env, never the config.
Gated screens (build --gates)
Screens behind a stored flag (onboarding, consent, permission prompts, an
@AppStorage or UserDefaults bool) can't be reached by tapping. With an app
model, build --gates adds a short pass per such gate after the crawl: for
each gate whose screens the crawl missed, a fresh Simulator launched like
signed-in (its launchEnvironment and launch arguments; signed-out when
signed-in has no launchEnvironment) plus -<key> NO (or YES, as the
crawl-plan.md suggestion says). Each pass runs for gatePassSeconds (150 by
default), goes two taps deep, and keeps only the screens the state's map
doesn't have yet. They join that state's map, tagged with the gate (gate in
ios-crawl/signed-in+gate:<key>/graph.json and the manifest), and a model
screen reached this way counts as reached. Passes run up to parallel at
once, after the states. build says why it skips them (no app model, no
gates, no stored keys, nothing missed behind them).
Coverage levels
thorough (the default) taps everything except the never-tap words and
switches above. That includes controls that open a share, invite, call or
report flow (the sheet is screenshotted, nothing inside it is tapped) and
controls worded like they change saved state: going, interested, RSVP,
attend, favourite, follow, like, rate, mark as read, dismiss, clear, archive,
resolve, hide, mute, turn on/allow notifications. Those are undone when they
visibly flip, but a change the crawler can't see stays, which is why this
level is meant for a test account. A control that leaves the app (a website,
Maps, a "Wants to Use … to Sign In" prompt) gets one screenshot, shown as
↗ <label> (outside the app), and the crawler comes straight back. A web
sign-in prompt is cancelled, never continued.
careful leaves all of those words alone as well, and treats a pushed page
that shows one of them in its text as look-only. Switch with build
--coverage careful, or "coverage": "careful" in the ios block (the flag
wins).
AI planning (optional, --ai)
build --ai <provider> shows each new screen to a vision model before tapping
it. The model names the screen, groups screens of one kind (one event page
explored, not twenty), spots the same screen shown with a banner or rotating
text, and labels every control: opens, changes data, leaves the app, text
input, or filter/tab. Anything it calls "changes data" is never tapped, and
neither are switches. One exception, in the signed-out state only: a sign-in
button ("Sign in with …", "Log in", "Sign up", "Continue with …", "Create
account") is tapped anyway, since a system sign-in consent is cancelled and
nothing is typed (commit words, neverTap and the app model still win). At thorough coverage its "don't tap" otherwise only
holds for text inputs: controls that open, filter or leave the app are tapped
anyway, as is a control it didn't mention. At careful it must approve every
tap, and it can only unblock a word on the lists above when it is 90%+ sure
the control is a filter or plain navigation (e.g. a "Going" filter chip). If the provider can't serve the crawl at all (bad
key, unknown model, quota used up) the crawl stops rather than carrying on
without AI vetting; a one-off error leaves that screen look-only.
| --ai | Key (environment) | Default model |
|---|---|---|
| claude | ANTHROPIC_API_KEY | claude-haiku-4-5-20251001 |
| openai | OPENAI_API_KEY | gpt-6-luna |
| gemini | GEMINI_API_KEY (or GOOGLE_API_KEY) | gemini-3.6-flash |
| openai-compatible:<model> | OPENAI_BASE_URL (+ OPENAI_API_KEY if needed) | — (Ollama, LM Studio, OpenRouter…) |
Add :model to pick another, e.g. --ai openai:gpt-6-sol. One call per new
screen, except a screen the app model's actions cover (80%+ of its controls)
or one of the same kind as a screen already planned: those reuse what is known.
Screenshots go as JPEG, at most ios.ai.imageWidth px wide (default 512). The
calls, tokens and cost are printed at the end. Screenshots and control labels
go to that provider under your key, so use a test account when signed in. Try
build --dry-run --ai claude first: it plans the first screen and lists what
it would tap, without tapping. If the provider fails, that screen falls back
to the rules.
View types (automatic)
On the Simulator, Loupio injects a small debug library (ios/LoupioProbe, built
with the crawler on your Mac, never added to your project) into your app with
DYLD_INSERT_LIBRARIES. When a screen appears it logs the screen's view type,
ItemDetailView for a SwiftUI screen or the class name for a UIKit view
controller, through the same log channel as screen keys (type:ItemDetailView).
That type is the screen's exact identity, and its app model screen (model ids
are type names), with no change to your app. A screen key, if you log one, wins.
It sees screens that get a view controller of their own: pushes, sheets, the
app's root view, UIKit screens. A tab's root, a NavigationStack's root and a
screen a view swaps in place (if signedIn { … } else { … }) log nothing and
are matched as before. Turn it
off with "injectIdentity": false in the ios block. Types show in graph.json
(type).
Screen keys (optional, recommended)
iOS gives Loupio no screen IDs from outside your app, so by default it tells screens apart from what's on them. If your app logs a name when each screen appears, Loupio uses that name as the screen's exact identity, title and kind: twenty venue pages with one key are one screen on the map.
import os
private let loupio = Logger(subsystem: "loupio", category: "screen")
// In your existing screen-tracking helper (PostHog `screen`, Firebase
// `screen_view`, Segment `screen`), or in each screen's .onAppear:
#if DEBUG
loupio.info("\(name, privacy: .public)") // .public, or iOS logs it as <private>
#endifOr run npx loupio kit and add .loupioScreen("Settings") to each screen's
root view (see "Signing in automatically").
Treat screen keys like analytics event names:
- Stable and readable:
VenueDetail,Inbox,Settings. - No IDs, numbers or user data. Keys with digits or IDs are ignored with a warning, and so are keys when more than 3 different ones show up on one screen layout: that means they carry data, not screen names.
- Debug builds only (
#if DEBUG). - Renamed a key? Map the old name so maps stay comparable:
"screenAliases": { "VenuePage": "VenueDetail" }in theiosblock ofvisual-plan.config.json.
Keys show in graph.json (key) and in crawl-plan.md. Screens without a
key fall back to the default matching.
Loupio also scores the crawl against the keys in your Swift source: Screen
keys: 12 of 19 mapped; missing: ArtistEvents, EventTickets. It looks for
string literals passed to loupioScreen("…") or trackScreen("…"). If your
helper has another name, set "screenKeyPattern" in the ios block: a regex
with one capture group for the key, e.g. "logScreen\\(\\s*\"([^\"]+)\"".
Screen history (loupio history, loupio diff)
Every screen gets a stable id that stays the same from build to build:
ios:<module>.<TypeName>[#variant], e.g. ios:antix.ArtistDetailView. The
TypeName is the screen's view type, else its app model screen, else its screen
key; the module is the last part of the bundle id. Instances share one id
(Artist A and Artist B are one screen). #variant tells one type's modes
apart: the enum case on the app model edge that opened it, or the control's
label, digits dropped ("See all 162" is #see-all), when two differently
labelled controls (not rows of a list) on one screen open the same type. A
screen with none of these, and none the app model makes of the control that
opened it, gets fp:<fingerprint>, which changes with its contents. Ids show in
graph.json and the map (sid).
Each build writes .visual-plan/history/<time>-<git sha>.json (the last 50
are kept): the app's version and build from its Info.plist, the git commit, and
per screen its title, state, screenshot hashes (sha1 and a 64-bit dHash),
its structural control labels (not list rows, which are counted per list, nor
dates, free text or its own heading) and the screens it leads to; with an app
model, its screens and why each one missed was, and why each state's crawl
stopped. No screenshots are kept.
npx loupio history # the builds, oldest first
npx loupio diff # the last two
npx loupio diff 20261001T0900 -1 # a run by id prefix, or -1 the newest, -2 the one before
npx loupio diff --write-aliases # also print the renames as screenAliases; --yes writes themdiff lists screens added, removed, renamed and changed (the screenshot looks
different, controls came or went, a list got its first rows or lost its last, or
links came or went on a screen both crawls worked through). A screen the newer
crawl didn't get to is not reached, with why (a reason from the crawl, its
deadline, no gate passes this time), not removed: removed is only a screen the
app model no longer has, or one its parent, worked through, no longer leads to.
Screens outside the app are never removed or changed, and fp: ids are listed
as unstable, never added or removed. A rename is a screen gone and one
new with the same source file or route, or screenshots that look alike (90%+ of
the dHash), and 60%+ of their controls in common. screenAliases apply to the
type names in ids too, so after --write-aliases --yes a renamed screen is the
same screen.
The viewer gets the last 10 builds (canvas.html and push): the Library marks
screens new, changed or renamed since the build before, its Changes toggle
shows only those, each screen's panel has a strip of its earlier builds, and the
Changes tab lists everything with the controls added and removed, and,
dimmed, what wasn't reached this time.
Fixing a missed screen
crawl-plan.md and the map list the doors the crawler didn't open, per
screen. Three ways to get what is behind one:
Tell the crawler, for every crawl from now on. In the ios block:
"alwaysTap": ["Settings > Export my data"],
"neverTap": ["Invite friends"]An entry is "Label" (on any screen) or "Screen > Label". Both parts match
as case-insensitive "contains": the label against the control's label, the
screen against the screen's title or screen key. neverTap wins over
everything. alwaysTap wins over the word lists (the never-tap words
included, so mind what you name), the AI planner and the "rest of this list
opens the same screen" rule, but a switch is still never tapped. Up to 100
entries each.
Tap one control now, keeping the rest of the map.
npx loupio build --only "Settings > Delete account"This loads the last crawl, replays the taps that led to that screen, taps
that control whatever the word lists say (never a switch), and explores what
it opens with the normal rules. New screens and edges are added to the map;
nothing else is crawled again. Repeat --only for several controls, and add
--state signed-in to do it in one state only. It needs a full build first
(it reads .visual-plan/ios-crawl/<state>/crawl-state.json), and it skips a
target with a warning if the saved taps no longer lead to that screen.
Show it the way yourself.
npx loupio capture --state signed-inThis opens that state's saved Simulator and launches the app. Tap through it
by hand; Loupio only watches (it never taps, scrolls or types). Each screen
you stop on for a second or two is added to the map if it isn't there yet,
with an edge from the screen you came from, labelled (by hand). Press Enter
in the terminal when you are done: the report and canvas are rebuilt, and it
prints how many screens were added. Without --state it uses signed-in if
that Simulator exists, otherwise signed-out (on the saved Simulator itself,
so what you do there stays on it). --ai <provider> names the captured
screens as in a build. --only can't start from a captured screen, because
nobody recorded which control you tapped to get there.
A full build starts each state's map again from nothing, captured screens
included: put the control in alwaysTap if you want it crawled every time.
Other commands: ios install (rebuild and reinstall the app into both
Simulators, keeping the sign-in), ios clean [--all] (delete leftover crawl
Simulators; --all also deletes the two saved Simulators), build --state
signed-in (crawl one state only), build --coverage careful (tap less; see
"Coverage levels").
If Simulators on your newest iOS runtime never finish booting, set
LOUPIO_SIM_RUNTIME=18.2 (any installed version prefix) and re-run init.
init options: --scheme <name>, --app <path/to/App.app> (skip building;
use an app you built yourself), --device-type "iPhone 16", --force
(overwrite the config), --trust (pass -skipMacroValidation
-skipPackagePluginValidation to xcodebuild; only for projects you trust,
since building runs the project's own build scripts).
Config (visual-plan.config.json, written by init):
{ "ios": { "bundleId": "com.example.app", "project": "App.xcworkspace", "scheme": "App" } }Optional keys: states (["signed-out", "signed-in"] by default, or
whichever saved Simulators exist, plus data states: see "Test data per
state"), gatePassSeconds (150: see "Gated screens"), launchArguments (per state, e.g.
{ "signed-in": ["-UITestAutoLogin", "YES"] } for a debug-only auto-login
hook in CI; never put credentials here, this file is committed),
maxScreens (default 40, per state), maxDepth (5), maxSeconds (900, per
state), maxScrollPages (6: how far down a screen the crawler scrolls for
controls below the fold), maxControlsPerScreen (60), rowsPerList (2: how
many rows of a list are tapped before the rest are left alone because those
rows all open the same kind of screen), skip, coverage ("thorough" or "careful"), alwaysTap / neverTap (see "Fixing a missed screen"), targetedPass
(default true: with an app model, once the crawl is done and time is left, go
back to each mapped parent of a model screen not reached, look at it afresh
and tap the control the model says opens it, through the same safety rules,
trying up to 3 mapped instances of that parent (another event's page can have
controls this one's data doesn't), one per route first (an event opened from
one list, then one from another list, before a second from the first); a
trigger on no route's instance is "no data". It gets 15% of maxSeconds when
the model has fewer than 15 non-root screens, 35% when it has more than 40,
else 25%; the crawl stops there either way. build --no-targeted
skips it for one run), injectIdentity (default true: see "View types"), parallel (see below), and device (legacy: crawl that Simulator UDID as-is,
without saved states).
parallel ("auto", the default, or 1 to 4) is how many Simulators crawl at
once, for more screens in the same time at no extra AI cost (one planner,
one usage line). The states are crawled side by side, and Simulators left
over split a state crawled on fresh Simulators (signed-out, or a state signed
in by launchEnvironment): once its first screen is surveyed, its subtrees
(each tab, or each control's screen) are shared out among fresh Simulators
made like its own, and merged back into one map. A state crawled on its saved
Simulator stays on that one (its sign-in can't be copied). "auto" is the
smallest of 4, half the Mac's cores and one per 1.5 GB of free memory, and 1
when the Mac is busy (load average over its core count); build prints what
it chose, e.g. Parallel: 3 Simulators (8 cores, 12 GB free). maxScreens
and maxSeconds still hold for the state as a whole. Extra Simulators are
deleted when the state is done, on errors and on Ctrl-C. With an app model, crawl-plan.md lists each model
screen not reached with why (reached but not identified, with the model screens
it could be, trigger skipped, parent mapped but trigger not tried, no data,
gated, other state, out of time, other variant; across states, the reason is from the state
the screen belongs to), plus launch arguments that flip a stored gate and deep links to try.
"Other variant": map-code --static records A/B routers (a view that only
picks one of two or more screens with the same parameters, e.g. from a
feature flag) as variants; once one is reached, the others, and what only
they open, aren't counted as reachable. When the deadline cut the crawl short
with more than 5 model screens left out of time or behind an unreached parent,
the crawl prints a tip to raise maxSeconds (also in crawl-plan.md). Without init, build crawls whatever is on the
booted Simulator.
launchEnvironment sets environment variables in the app, per state. Each
entry maps the app's variable to the name of a variable on your Mac that
holds the value, so the config only ever holds names:
{ "ios": { "launchEnvironment": { "signed-in": { "APP_SESSION": "MYAPP_TEST_TOKEN" } } } }The value comes from your shell (MYAPP_TEST_TOKEN=… loupio build) or,
failing that, from .visual-plan/secrets.env (KEY=value lines, #
comments, quotes optional; .visual-plan/ is git-ignored once build has
run; keep the file chmod 600, Loupio warns otherwise). A missing variable
stops that state with its name. Values are replaced with ‹secret› in
crawl.log and in errors, and reach the app through the crawler or
SIMCTL_CHILD_* variables, never a command line. Every launch (crawl,
capture, ios login) also sets LOUPIO_CRAWL=1 and LOUPIO_STATE
(signed-out / signed-in), so your app can tell it is being mapped. A
state with a launchEnvironment needs no ios login: without a saved
Simulator it is crawled on a fresh one each time; with one, that Simulator is
used and the environment applied too. Recipes for your app's side: "Signing
in automatically".
Output: everything goes into .visual-plan/ in your repo (it ignores
itself in git): canvas.html (the interactive map, works offline),
report.html, and the raw screenshots. Signed-in screenshots can show
personal account data, so push asks for --yes before uploading them.
How it works: ios/ holds a generic XCUITest bundle (generated from
ios/project.yml with XcodeGen) that drives any installed app through
XCUIApplication(bundleIdentifier:), so nothing is added to your app's
project. The bundle only executes commands (snapshot, tap, screenshot); every
decision is made by the CLI (src/crawler/, rules unit-tested with npm test).
build --dry-run screenshots the first screen and lists what it would tap,
without tapping; each screenshot gets an element list (screens/NNN.json). Screens are told apart by nav title, selected tab, and controls
outside scroll views, so a feed that loads new data does not count as a new
screen. Only the first row of each list is tapped. System permission alerts
are pre-granted where simctl allows and tapped through otherwise. The
first run compiles the crawler (about a minute); a small app takes 5 to 10
minutes per state. ios/VPFixture is a small test app used to check the
crawler end to end.
push needs one-time configuration pointing at your deployed viewer:
VISUAL_PLAN_TOKEN=<token> visual-plan configure --server-url <url>or omit the env var to be prompted (masked input, nothing echoed). Avoid
--token <token> if you can — it lands in shell history and is visible to
other local users via ps aux; it still works but prints a warning.
serverUrl must be https:// (loopback exempted for local testing) — the
push token is sent as a bearer header, so an http:// URL would leak it in
cleartext. Saved to ~/.visual-plan/config.json (mode 600, directory mode
700, no window where either is briefly world-readable).
The Railway viewer (server/)
A small Express app: POST /plans (bearer-auth with VISUAL_PLAN_TOKEN)
stores a pushed report under a random UUID; GET /plans/:id serves it back,
gated by a separate per-plan token generated at push time — so a leaked
report URL only exposes that one report, not the push token or every other
report. Storage is a Railway volume mounted at /data
(VISUAL_PLAN_DATA_DIR), so reports survive redeploys.
Deployed as Railway project visual-plan, service visual-plan-server.
Security notes
devServerUrlinvisual-plan.config.jsonmust resolve to localhost/127.0.0.1/::1 — anything else is rejected (SSRF guard), since that config file could come from an untrusted repo.- All user-controlled strings (file paths, screenshot paths, error text) are
HTML-escaped before going into
report.html; diagram labels are safelisted to[a-zA-Z0-9 ._-]before being embedded in the Mermaid SVG. - Screenshot filenames are safelisted the same way before touching the filesystem (no path traversal via crafted paths).
- Server token comparisons use
crypto.timingSafeEqual.
