Saltar al contenido principal

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.

LayerSourceWhat a "change" looks like
Backend APIapps/backendNestJS on :4000 locally; Cloud Run in staging/production
Merchant UIapps/frontend-pwaVite on :5173 in a browser. Inside the app it is a zip, not the Vite dev server
Native shellapps/mobileExpo/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:

CapabilityiOS SimulatorAndroid EmulatorPhysical device
Sign-in, PWA screens, API callsYesYesYes
Session lock / Keychain durabilityNo — device_lock_absent is expectedUnreliableYes
Bluetooth / USB printersNoNoYes
Local-network (Bonjour) discoveryNoLimitedYes
adb reverse / USB Metron/a (shares the Mac network)Needed for localhost APINeeded on Android USB

Use a simulator to iterate. Use a tablet for manual verification (MV-1…MV-5), printing, and session durability.

Prerequisites​

RequirementWhy
Node 22, pnpm 10Workspace 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 cmakeCocoaPods; 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:

  1. apps/frontend-pwa/.env
  2. apps/frontend-pwa/.env.local (gitignored — this is the one to edit for local)
  3. apps/frontend-pwa/.env.mobile (only if you create it)
  4. 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​

TargetWorking VITE_PUBLIC_API_URL
iOS Simulator + local backendhttp://localhost:4000 — the simulator shares the Mac's loopback
Android Emulator + local backendhttp://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 backendhttp://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 staginghttps://api.staging.flowandgrow.tech
Any device, pointing at productionhttps://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:

PortWhy
8081Metro — the JS bundle. Without this, the app sits on the splash waiting for a bundler that is on the laptop, not the tablet
4000Local 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 -- --device by 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:

PlatformInspector
Android (device or emulator)Chrome → chrome://inspect → the WebView at http://127.0.0.1:47285
iOS SimulatorSafari → Develop → Simulator → the 127.0.0.1:47285 page
Physical iOSSafari → 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​

SignalWhere
Cloud Run (backend, PWA)GCP Console → Cloud Run → service → Logs, or the GitHub Actions run that deployed
Native / OTA crashesSentry on the mobile project. Debug binaries (__DEV__) do not send Sentry — enabled: !__DEV__ in apps/mobile/src/observability/sentry.ts
Installed release identitySHELL_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:

  1. ./scripts/build-mobile-web.sh
  2. Copy zip + manifest into apps/mobile/assets/web/
  3. 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 changedStaging 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
URLHost
APIhttps://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:

  1. GitHub → Actions → Deploy to Production → Run workflow
  2. A teammate with environment approval accepts
  3. Only changed apps deploy to Cloud Run on barto-prod
URLHost
APIhttps://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.

ChannelWho has itTypical API
developmentpnpm ios / pnpm android debug installsWhatever you baked into the zip
internalInternal TestFlight / internal APKStaging
merchantApp Store / PlayProduction

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 changedDo this
apps/mobile/src/** or App.tsxMetro reload (pnpm start if it is not running)
Native module, app.json (including orientation), plugin, Info.plist / Android permissionspnpm 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_URLRebuild 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 changedLocalStagingProduction
Backend onlySave; Nest watch. App/browser pick it up if the baked URL still points hereMerge to main → Cloud Run stagingManual Deploy to Production
PWA only, browserVite HMR on :5173Merge to main → Cloud Run PWAManual production deploy
PWA only, inside the appRebuild zip, restage, Metro reload / Aboutpublish-mobile-release.sh internal (after baking staging API)publish-mobile-release.sh merchant (clean tree, production API)
Mobile JS (apps/mobile/src)Metro reloadeas update via the publish script if fingerprint unchanged; otherwise a new internal binarySame on merchant / store
Mobile native / app.json / pluginsnative:prebuild + pnpm ios / pnpm androidNew internal binaryNew 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.