Running FlowPOS locally, and seeing changes in staging and production
This is the operator guide for apps/mobile: how to install a development
build, how to read logs, and how a change in the backend, the PWA, or the
native shell reaches a laptop browser, a simulator, a physical tablet, staging,
and production.
It is a development build with custom native modules. Expo Go will not run it. Ignore the QR code Metro prints, and do not scan it into Expo Go. Open the installed FlowPOS app instead.
Architecture of what you are launching: Mobile Shell.
How the pieces fit
The installed app is a native container. It extracts a zip of frontend-pwa
and serves it at the pinned origin http://127.0.0.1:47285. The merchant UI is
that web build. The container owns sessions, the outbox, printing, and OTA.
| Layer | Source | What a "change" looks like |
|---|---|---|
| Backend API | apps/backend | NestJS on :4000 locally; Cloud Run in staging/production |
| Merchant UI | apps/frontend-pwa | Vite on :5173 in a browser. Inside the app it is a zip, not the Vite dev server |
| Native shell | apps/mobile | Expo/React Native. Debug JS comes from Metro; store/TestFlight JS is inside the binary (or an EAS Update) |
VITE_PUBLIC_API_URL is baked into the zip at build time. Reloading Metro
does not change which API the WebView calls. The WebView origin stays
http://127.0.0.1:47285 either way — that origin is what CORS allowlists
(main.ts). The API host is independent.
Physical vs virtual devices
Simulators and emulators are fine for shell bring-up, layout, API wiring, and most PWA UI. They are not a substitute for hardware on:
| Capability | iOS Simulator | Android Emulator | Physical device |
|---|---|---|---|
| Sign-in, PWA screens, API calls | Yes | Yes | Yes |
| Session lock / Keychain durability | No — device_lock_absent is expected | Unreliable | Yes |
| Bluetooth / USB printers | No | No | Yes |
| Local-network (Bonjour) discovery | No | Limited | Yes |
adb reverse / USB Metro | n/a (shares the Mac network) | Needed for localhost API | Needed on Android USB |
Use a simulator to iterate. Use a tablet for manual verification (MV-1…MV-5), printing, and session durability.
Prerequisites
| Requirement | Why |
|---|---|
| Node 22, pnpm 10 | Workspace baseline |
Backend on :4000 (docker-compose up -d, then pnpm --filter backend run start:dev) | The packaged PWA and the browser PWA both call the API |
Xcode + xcodebuild -downloadPlatform iOS (~8.5 GB, once) | Xcode ships without device or simulator support. Until this finishes, xcodebuild refuses both |
brew install cocoapods and brew install cmake | CocoaPods; react-native-static-server builds lighttpd from source |
| Android Studio (SDK + Platform Tools), API 28+ | Emulator images, USB debugging, adb |
Apple team Fixx Sociedad Anonima (4LL4BEN647) | Bundle id com.rpapos.flowpos. Do not create a distribution cert, revoke portal items, or pass -allowProvisioningDeviceRegistration without asking — see apps/mobile/CLAUDE.md |
ios/ and android/ are gitignored. They are generated by native:prebuild on
your machine.
1. Package the web build the shell will serve
The native app does not load Vite's dev server. It extracts a zip from
apps/mobile/assets/web/ and serves it at http://127.0.0.1:47285.
# From the repo root.
# Set VITE_PUBLIC_API_URL first — see env files below.
./scripts/build-mobile-web.sh
cp build/mobile-web/www.zip apps/mobile/assets/web/www.zip
cp build/mobile-web/manifest.json apps/mobile/assets/web/manifest.json
Copy the files. Do not symlink — Metro resolves the asset from disk at bundle time.
Env files Vite actually reads
./scripts/build-mobile-web.sh runs vite build --mode mobile. Vite loads, in
order, with later files winning:
apps/frontend-pwa/.envapps/frontend-pwa/.env.local(gitignored — this is the one to edit for local)apps/frontend-pwa/.env.mobile(only if you create it)apps/frontend-pwa/.env.mobile.local
There is no committed .env.mobile. Copy apps/frontend-pwa/.env.example to
.env.local if you do not already have one, then set VITE_PUBLIC_API_URL
there. Reloading Metro does not pick up a new URL; rebuild and restage the zip.
When you later publish an OTA, the zip is rebuilt from
whatever env files are on that machine. A merchant publish with
http://localhost:4000 baked in will ship localhost to production devices.
Which API URL to bake
| Target | Working VITE_PUBLIC_API_URL |
|---|---|
| iOS Simulator + local backend | http://localhost:4000 — the simulator shares the Mac's loopback |
| Android Emulator + local backend | http://localhost:4000 and adb reverse tcp:4000 tcp:4000, or bake http://10.0.2.2:4000 (the emulator's alias for the host) |
| Android, USB, local backend | http://localhost:4000 and both reverses in USB reverse after the device is connected. Reverse does not persist across unplug |
| Physical iOS (iPhone / iPad) | There is no adb reverse. localhost on the device is the device. Bake the laptop's LAN IP (http://192.168.x.x:4000) or a reachable staging URL. Device and laptop must be on the same Wi-Fi. The backend already listens on 0.0.0.0 |
| Any device, pointing at staging | https://api.staging.flowandgrow.tech |
| Any device, pointing at production | https://api.flowandgrow.tech |
LAN IP on a Mac: ipconfig getifaddr en0. It changes when you switch Wi-Fi or
tether. Rebuild the zip when it does.
2. Generate native projects (once, and after native deps change)
cd apps/mobile
pnpm install
pnpm native:prebuild
native:prebuild is required whenever you add a native module. pnpm add alone
leaves the JS import present and the binary without the module; the app then
dies with Cannot find native module 'X'.
3. Install the native binary
One -- before --device. A second -- is a different command and is
wrong — see the extra-dash trap. Prefer cd apps/mobile so that
is the only spelling you have to remember.
Each of the commands below builds, installs, and starts Metro. You do not
need a separate pnpm start for the first launch. One Metro serves both
platforms — if it is already running from pnpm ios, pnpm android can skip
starting a second one.
Ignore the Metro QR code. Open the installed FlowPOS app.
iOS Simulator
# List available simulators
xcrun simctl list devices available
cd apps/mobile
pnpm ios # default iPhone simulator
pnpm ios --device "iPad (10th generation)" # named simulator (still one dash)
Boot a simulator from Xcode → Window → Devices and Simulators if you want a specific iPad size before running.
First Xcode install: wait until xcodebuild -downloadPlatform iOS has finished
or the destination will be refused.
Physical iOS device
USB-connected iPhone or iPad, unlocked, trusted on this Mac.
cd apps/mobile
pnpm ios --device # picks a plugged-in iPhone / iPad
First launch: Settings → General → VPN & Device Management → trust the developer app. Until you do, iOS will not run the binary.
The device must already be on the team's device list. Do not pass
-allowProvisioningDeviceRegistration to register a new one without asking.
Android Emulator
# List AVDs (Android Studio → Device Manager is the usual way to create one)
emulator -list-avds
# Start one, then in another terminal:
emulator -avd Pixel_Tablet # name from the list above
cd apps/mobile
pnpm android # installs on the running emulator
Use an image with API 28+ (cleartext loopback). A tablet AVD is closer to the merchant layout than a phone.
If the zip baked http://localhost:4000, reverse the API port (Metro's 8081
is usually set for you):
adb reverse tcp:8081 tcp:8081
adb reverse tcp:4000 tcp:4000
Alternatively bake http://10.0.2.2:4000 and skip the 4000 reverse.
Physical Android device
USB debugging on, API 28+.
cd apps/mobile
pnpm android --device # picks a plugged-in Android device
Leftover Android package
If the tablet still has tech.flowandgrow.flowpos installed, uninstall it.
Both packages claim flowpos://, so expo run:android can open the old app
(blue splash, then a frozen black screen) instead of com.rpapos.flowpos.
adb uninstall tech.flowandgrow.flowpos
Then open FlowPOS (com.rpapos.flowpos) on the tablet — not the leftover
icon, if it is still on the home screen. Confirm with adb shell pm path com.rpapos.flowpos (a path means the new package is installed).
USB reverse (Android)
A debug Android build talks to the laptop over USB as localhost. Two ports:
| Port | Why |
|---|---|
8081 | Metro — the JS bundle. Without this, the app sits on the splash waiting for a bundler that is on the laptop, not the tablet |
4000 | Local backend, when the zip baked http://localhost:4000 |
adb reverse tcp:8081 tcp:8081
adb reverse tcp:4000 tcp:4000
Re-run both after every unplug. expo run:android usually sets 8081; it does
not set 4000, and neither survives disconnecting the cable. The same pair is
needed on an emulator that baked localhost rather than 10.0.2.2.
4. Metro after the app is already installed
Debug binaries load JavaScript from Metro (localhost:8081 / the LAN exp://
URL). Release / TestFlight / Play builds do not — JS is inside the binary
(or arrives later via EAS Update).
You need a running Metro when:
- you already installed the app and only changed JS (shell, splash hide, most
TypeScript under
apps/mobile/src/) - you used
pnpm ios -- --deviceby mistake and Metro never started - Metro was not already running in another terminal
cd apps/mobile
pnpm start
Then force-quit FlowPOS on the device/simulator and reopen it, or shake the device and tap Reload. Do not start a second Metro if one is already bound to 8081.
If Metro says Using Expo Go, press s to switch to the development build.
The QR code is still not the path — open the installed FlowPOS app.
pnpm ios / pnpm android already start Metro. A standalone pnpm start is
the reload path, not a third way to install.
5. What a healthy launch looks like
In the Metro terminal you should see, in order:
SHELL_SESSION_GATE …
SHELL_BUNDLE_READY … <webBuildVersion> …
SHELL_TRANSPORT_READY
SHELL_SESSION_STARTED … durable=true
durable=true means the device has an OS lock and the session may persist.
persist=false with device_lock_absent is expected on an unlocked tablet
and on every simulator — the app still runs.
You should also see a single SHELL_RELEASE … line pairing the container
identity with the packaged web build (webBuildVersion + webSha). That is
the line to compare against apps/mobile/assets/web/manifest.json.
Logs
A print or launch failure can live in the PWA, the bridge, a native adapter, or the API. Read the layer that owns the symptom.
Metro (debug builds)
The terminal that ran pnpm ios, pnpm android, or pnpm start. This is the
first place for SHELL_* lines and React Native console.log.
If Metro is not connected, you will not see JS logs here — fix reverse / the extra-dash trap first.
Android — logcat
From apps/mobile:
pnpm device:logs # follow printer / SHELL_ / ReactNativeJS
pnpm device:logs -- --all # everything from com.rpapos.flowpos
pnpm device:logs -- --crash # crash buffer only
pnpm device:logs -- --dump out.txt
The leading -- after pnpm device:logs is required so pnpm forwards the
script flags. Reproduce the bug after the "Cleared. Following…" line.
Raw equivalent:
adb logcat -d | grep SHELL_
iOS Simulator
Metro is usually enough. For the system log of the booted simulator:
xcrun simctl spawn booted log stream --level debug \
--predicate 'processImagePath CONTAINS "FlowPOS" OR eventMessage CONTAINS "SHELL_"'
Physical iOS
- Xcode → Window → Devices and Simulators → select the device → Open Console
- Or Console.app, device in the sidebar, filter
SHELL_/FlowPOS
There is no device:logs equivalent on iOS; use Xcode/Console plus Metro.
WebView (the PWA inside the app)
The zip is a normal web app. Use the platform Web Inspector:
| Platform | Inspector |
|---|---|
| Android (device or emulator) | Chrome → chrome://inspect → the WebView at http://127.0.0.1:47285 |
| iOS Simulator | Safari → Develop → Simulator → the 127.0.0.1:47285 page |
| Physical iOS | Safari → Develop → [device name] → same page. Enable Web Inspector on the device: Settings → Safari → Advanced |
Network failures ("Can't reach FlowPOS") show here as failed calls to
VITE_PUBLIC_API_URL.
Backend (local)
The terminal running pnpm --filter backend run start:dev. Nest watch logs
every request. http://localhost:4000/health must succeed from the same
host the zip baked.
Browser PWA (not the app)
Vite's terminal (pnpm --filter frontend-pwa run dev) plus the browser
DevTools on http://localhost:5173. This is a different process from the zip
inside the app — a HMR fix in the browser does not appear in the WebView
until you rebuild and restage the zip.
Staging and production
| Signal | Where |
|---|---|
| Cloud Run (backend, PWA) | GCP Console → Cloud Run → service → Logs, or the GitHub Actions run that deployed |
| Native / OTA crashes | Sentry on the mobile project. Debug binaries (__DEV__) do not send Sentry — enabled: !__DEV__ in apps/mobile/src/observability/sentry.ts |
| Installed release identity | SHELL_RELEASE on a debug device; handshake / About on a store build |
Sentry events are tagged with expo-channel, expo-update-id, and
expo-runtime-version. That is how you tell an embedded store binary from an
OTA that landed on top of it.
Seeing backend and PWA changes
The browser PWA and the app PWA are the same source, delivered two ways.
Cloud Run deploys the browser. EAS Update deploys the zip inside the app.
Merging to main does not publish an EAS Update — that is a separate
command (mobile staging/production).
Local
# Infra + API
docker-compose up -d
pnpm --filter backend run start:dev # :4000, watch
# Browser PWA — HMR
pnpm --filter frontend-pwa run dev # :5173
Open http://localhost:5173. Vite hot-reloads UI. The backend restarts on
save. Schema changes still need pnpm run migration:local:push and
pnpm run generate:types.
Inside the mobile app, the WebView does not speak to Vite. After a PWA or
VITE_PUBLIC_API_URL change:
./scripts/build-mobile-web.sh- Copy zip + manifest into
apps/mobile/assets/web/ - Restart Metro (or ensure it saw the new files) and reload the native app, or open About → Check for updates. Development remounts only when the zip hash changed. An unchanged zip leaves the session in place.
A running local backend is picked up immediately by both the browser and the app (no zip rebuild) as long as the baked URL still points at it.
Staging
Branch from main, PR to main, merge. GitHub Actions
(deploy-staging-from-main.yml) deploys only the apps whose paths changed.
| You changed | Staging effect |
|---|---|
apps/backend/**, packages/backend/database/**, packages/global/** | Cloud Run flowpos-backend on project barto-dev |
apps/frontend-pwa/**, packages/global/** | Cloud Run flowpos-frontend-pwa |
| URL | Host |
|---|---|
| API | https://api.staging.flowandgrow.tech |
| PWA (browser) | https://app.staging.flowandgrow.tech |
Wait for the Actions run to finish, then verify in a browser against those URLs. Full workflow: Deployment workflow.
The installed app still runs whatever zip it last extracted. Point a debug
build at staging by baking the staging API and restaging the zip, or publish
OTA to the internal channel (below).
Production
Staging must already be verified. Then:
- GitHub → Actions → Deploy to Production → Run workflow
- A teammate with environment approval accepts
- Only changed apps deploy to Cloud Run on
barto-prod
| URL | Host |
|---|---|
| API | https://api.flowandgrow.tech |
| PWA (browser) | https://app.flowandgrow.tech |
Browser users see the new PWA as soon as Cloud Run revision is live. App
users do not, until you publish the matching zip to the merchant channel.
Seeing mobile-app changes
Three kinds of change, three delivery paths. Channels are baked into the
binary at build time (apps/mobile/eas.json); publishing to one cannot
reach another.
| Channel | Who has it | Typical API |
|---|---|---|
development | pnpm ios / pnpm android debug installs | Whatever you baked into the zip |
internal | Internal TestFlight / internal APK | Staging |
merchant | App Store / Play | Production |
Debug builds do not consume EAS Update. About → Check for updates remounts
the local zip. Store/internal binaries download OTA from JS (startUpdateSync /
About → Check for updates) and apply on the next cold start, or
immediately from About after the merchant confirms they are not mid-ticket.
An embedded binary will not fetch a channel head whose
webBuildVersion is older than the zip it already ships — the same class
of hole as desktop treating packaged !== sha as "behind". Devices already
on an OTA still follow a republish (the rollback drill). The container
never calls Updates.reloadAsync() unattended.
That gate lives in the native binary (checkAutomatically: NEVER plus the
JS compare). A till that predates it still has Expo download on load until
it gets a new store/internal install. eas update alone is not enough.
Local
| You changed | Do this |
|---|---|
apps/mobile/src/** or App.tsx | Metro reload (pnpm start if it is not running) |
Native module, app.json (including orientation), plugin, Info.plist / Android permissions | pnpm native:prebuild, then pnpm ios / pnpm android (add --device for hardware). Metro reload does not rewrite the Android / iOS project |
PWA UI or VITE_PUBLIC_API_URL | Rebuild and restage the zip, then reload or About → Check for updates |
Staging (internal channel)
JS + packaged web, same native fingerprint:
# Bake the staging API into the zip. Dirty tree is allowed on `internal`
# (you get a warning); `merchant` refuses a dirty tree.
# In apps/frontend-pwa/.env.mobile.local (or the env you use for this publish):
# VITE_PUBLIC_API_URL=https://api.staging.flowandgrow.tech
./scripts/publish-mobile-release.sh internal --message "why this ships"
That script rebuilds the zip, copies it into apps/mobile/assets/web/, and
runs eas update --channel internal. Devices on internal adopt it on the
next cold start (or immediately from About).
--dry-run stages the zip and stops before eas update.
Native changes (new SDK, permission, runtimeVersion fingerprint drift)
cannot go over the air. Build a new binary with the internal EAS profile
and put it on TestFlight internal / an internal APK. See
Store submission.
apps/mobile/** is not in the Cloud Run path filter. Merging a shell
change to main does not install anything on a phone.
Production (merchant channel)
Same publish command, cleaner bar:
# Working tree must be clean. Bake production:
# VITE_PUBLIC_API_URL=https://api.flowandgrow.tech
./scripts/publish-mobile-release.sh merchant --message "web <webBuildVersion>"
Ship native production changes through App Store / Play (merchant profile).
An expired OTA certificate permanently bricks updates for that binary —
certificate rotation.
Daily PWA work for browser users is the Cloud Run production deploy.
Daily PWA work for app users is this merchant publish. They are not
the same button. Product-level picture:
Printing topology — daily PWA changes.
What to rebuild when something changes
| You changed | Local | Staging | Production |
|---|---|---|---|
| Backend only | Save; Nest watch. App/browser pick it up if the baked URL still points here | Merge to main → Cloud Run staging | Manual Deploy to Production |
| PWA only, browser | Vite HMR on :5173 | Merge to main → Cloud Run PWA | Manual production deploy |
| PWA only, inside the app | Rebuild zip, restage, Metro reload / About | publish-mobile-release.sh internal (after baking staging API) | publish-mobile-release.sh merchant (clean tree, production API) |
Mobile JS (apps/mobile/src) | Metro reload | eas update via the publish script if fingerprint unchanged; otherwise a new internal binary | Same on merchant / store |
Mobile native / app.json / plugins | native:prebuild + pnpm ios / pnpm android | New internal binary | New store binary (merchant) |
Traps we have already hit
Extra -- skips Metro
# Wrong when run from apps/mobile — Expo treats everything after `--` as extra
# args and skips the dev server. The log line is "Skipping dev server". The
# binary installs, then sits waiting on http://localhost:8081 (or shows only
# the splash).
pnpm ios -- --device
pnpm android -- --device
# Right, from apps/mobile
pnpm ios --device
pnpm android --device
pnpm ios # simulator
pnpm android # emulator
From the repo root, pnpm needs -- to forward flags into the script, and
that -- is eaten:
pnpm --filter @flowpos-workspace/mobile run ios -- --device
# → expo run:ios --device (correct)
Prefer cd apps/mobile && pnpm ios --device so the extra-dash trap is the
only spelling you have to remember.
Solid brand-blue screen
app.json splash background is #208AEF. App.tsx must call
SplashScreen.hideAsync() once React has taken over. If that call is missing,
Metro can still log SHELL_SESSION_STARTED while the merchant sees only blue —
the WebView is running underneath the overlay.
If hide is in place and you still see only blue: Metro is not connected
(adb reverse tcp:8081 missing), or Android opened the leftover
tech.flowandgrow.flowpos package — see leftover Android.
Landscape tablet, phone-width UI
app.json used to set "orientation": "portrait". Android then keeps the
activity portrait and pillarboxes it on a landscape Pixel Tablet — black bars
on both sides, Tailwind md: breakpoints never fire. The lock is now
"default" (follow the device). Changing it still requires
pnpm native:prebuild and a native rerun; Metro reload does not rewrite
AndroidManifest.
"Can't reach FlowPOS"
The zip's VITE_PUBLIC_API_URL is unreachable from the device. Typical causes:
localhost baked for a physical iPad, adb reverse tcp:4000 not run (or lost
after unplug), laptop firewall, wrong LAN IP after switching networks. Rebuild
the zip with a URL the device can actually open — see API URL.
The iOS Simulator can use localhost; a physical iPad cannot.
Cloud Run updated, app did not
Deploying frontend-pwa to staging or production updates the browser. The
app keeps serving the zip it already extracted until you restage locally or
run publish-mobile-release.sh for that channel.
Metro: incompatible React versions
The workspace pins react and react-dom to 19.2.3 in the root
package.json pnpm.overrides (Expo SDK 57). Other apps in the monorepo
can pull a newer react-dom (for example 19.2.4). Metro then refuses the
bundle:
Incompatible React versions: The "react" and "react-dom" packages must
have the exact same version.
Do not "fix" this by upgrading react inside apps/mobile. Restore the
overrides, pnpm install from the repo root, and rebuild. apps/mobile/app.json
also sets "platforms": ["ios", "android"] so Expo does not try to web-bundle
this native shell.
Airplane-mode tests against a debug build
A debug build still pulls JS from Metro over USB (or the simulator's loopback). That is not offline. Use a Release build for MV-2. See Manual verification.
Thermal print on a saved receipt printer
Thermal print (sale, order/bill, cash-register closing) uses the location's
saved receipt printer — the same Epson TM-m30III (or other printer_config)
that already works via Test print. After a tap, Metro should log
SHELL_PRINT_SENT. The WebView claims the document-print job, encodes the
server thermal DSL, and enqueues one outbox print job.
Test print and Print Preview are unchanged. Print Preview still goes
through window.print() / AirPrint. Do not expect a thermal receipt from Print
Preview.
Related
- Architecture
- Manual verification — MV-1…MV-5, after it launches
- Store submission — TestFlight, Play, Sunmi
- Deployment workflow — merge to
main→ staging; manual production - Environments — Cloud Run services, path filters
- Printing topology — why daily PWA work is not a store release
specs/051-mobile-app-shell/quickstart.md— toolchain notes from the original spikeapps/mobile/CLAUDE.md— Apple-account standing rule