Mobile apps from plugins: a how-to, with Deep Research as the worked example

documentation/how-to-build-mobile-apps-from-plugins/README.md explains why Deep Research
(doxa-maps-spa, its app preset) is the plugin to wrap first: the seven production maps in one
phone-ready shell, token-free on MapLibre. It then walks the whole path: tools, the wiring check,
one command, what each step does, another plugin as the app, making it your own, and fixes.

The one command needed two small changes; the default app is unchanged:
- internal/set-app-start-page.mjs takes APP_START=<plugin>/<screen>[/<preset>] and opens that page
  instead of the UPG-100 list. Unset, it behaves and marks the page exactly as before. A malformed
  value, a page missing from the build, or links that would not resolve are refused, as before.
- package.json: phone:from-tree is sync, start page, verify, assemble, install from the committed
  tree, with no full rebuild (bun run build refuses without BUILD_ALLOW=1).
  phone:deep-research is phone:from-tree with Deep Research as the start page.

how-to.md links the guide, and two of its lines are corrected: webDir is public/dx-maps/, and
apk:debug's first step is the full build, which asks for BUILD_ALLOW=1.

Checked, with no Android tools: the start page written for doxa-maps-spa/doxa-maps-spa/app, then
the app root served and loaded in the shared browser at 390 and 1280. It showed the gallery
element, 15 tabs (the seven maps and the experimental ones) and a drawn map, with 0 console errors
and 0 failed requests. --check passed for APP_START and failed for the default until it was
restored; the default run still writes the UPG-100 page with its original marker. The Gradle and
adb steps are those bun run phone already uses; they were not run here.
This commit is contained in:
Doxa Maps 2026-09-30 10:04:34 +00:00
parent 59fd232a1f
commit f3fdf4bf37
4 changed files with 165 additions and 15 deletions

View file

@ -0,0 +1,131 @@
# How to build a mobile app from a plugin
The plugins in this repo are web pages, and an Android app can wrap them with no new code. This guide
turns one into an app you can install, using **Deep Research** as the worked example. It takes one
command once the tools are installed.
## Why start with Deep Research
Deep Research is the plugin that is already a whole app. It is the app shell in
`plugin-designs/doxa-maps-spa/` (its `app` preset). One screen holds all seven production maps:
- Big Picture;
- Simple Research;
- Regions;
- Prayer Progress;
- UPG-100 List;
- Cities with Internet;
- Gospel Resources.
The experimental maps are there too. It is made for phones:
- a round **☰** at the bottom left switches maps;
- each legend starts folded;
- the ⓘ opens as a sheet over the map;
- posters, packets and cards print from the phone.
It draws with the free MapLibre engine, so it needs no Mapbox key. And it needs no new code: the app
is the same built folder the web uses, opened on Deep Research instead of on the Plugin Studio.
## The short version
```bash
bun install
bun run check:mobile # checks the app is wired up; needs no Android tools
bun run phone:deep-research # makes the app and installs it on your phone or emulator
```
Then open **Doxa Maps** on the phone.
## What you need
- This repo, cloned, and [Bun](https://bun.sh). The README's quick start covers both.
- **Android Studio**, which brings the Android SDK and `adb`, and **JDK 17**.
- An Android phone plugged in over USB with USB debugging turned on, or an emulator started from
Android Studio. `adb devices` should list it.
iOS is not set up yet. It needs a Mac with Xcode, and its project would live beside the Android one,
at `deployment/capacitor/ios`.
## Step by step
### 1. Check the wiring (no Android tools needed)
```bash
bun run check:mobile
```
This copies the built folder, `public/dx-maps/`, into the Android project (Capacitor's `cap sync`).
It then checks that the copy landed. It should end with `OK webDir copied`.
### 2. Make the app and install it
```bash
bun run phone:deep-research
```
This runs, in order:
1. `cap sync android`: copies the built folder into the Android project.
2. The start-page step, `APP_START=doxa-maps-spa/doxa-maps-spa/app`: the app opens Deep Research
instead of the Plugin Studio.
3. `cap:verify`: stops if the copy is empty, which would show as a blank white screen.
4. `./gradlew assembleDebug`: builds `app-debug.apk`.
5. `adb install -r`: installs it on the phone.
It uses the built folder as it is committed, so nothing is rebuilt. On a fresh clone that folder is
the current build.
### 3. Open it
The app is called **Doxa Maps**. On a phone it opens on Simple Research, because Big Picture needs a
larger screen. The round **☰** at the bottom left lists every map.
## Changed a plugin first?
Rebuild the one you changed, then run the same command:
```bash
BUNDLE=<plugin> bun x vite build && bun run tree && bun run fix:sri
bun run phone:deep-research
```
## Any other plugin as an app
It is the same command with another start page. Name the page's folder inside `public/dx-maps/`, as
`<plugin>/<screen>` or `<plugin>/<screen>/<preset>`:
```bash
APP_START=doxa-simple-map/doxa-simple-map bun run phone:from-tree # Prayer Progress
bun run phone:from-tree # unset: the UPG-100 list
```
Pick a page that works on a phone; Big Picture, for one, is desktop only. The start-page step
refuses a page that is not in the build, and one whose links would not resolve at the app's root.
## Make it your own
- **The name under the icon:** `app_name` in
`deployment/capacitor/android/app/src/main/res/values/strings.xml`.
- **Icon and splash screen:** the images in `deployment/capacitor/android/app/src/main/res/`.
- **Version:** `versionCode` and `versionName` in `deployment/capacitor/android/app/build.gradle`.
- **A store release:** it needs its own app id and a signing key. The key never goes in the repo.
## If something goes wrong
- **A blank white screen:** the copy is empty. Run `bun run check:mobile`; it must say
`OK webDir copied`.
- **The app opens the Plugin Studio:** the start-page step did not run. Run
`bun run phone:deep-research` again.
- **`adb` finds no device:** turn on USB debugging and accept the prompt on the phone, or start an
emulator.
- **Maps without their data:** several maps load live data, so the phone needs internet.
- **Using a Mapbox token:** the app's address is `https://localhost`. A token service must allow that
address (CORS). Deep Research's `app` preset uses MapLibre and needs no token.
## How it works
The app is a WebView around the same built folder the web serves; `capacitor.config.json` points at
it (`webDir`). The start-page step, `internal/set-app-start-page.mjs`, copies the chosen page to the
folder's root in the app's copy and fixes its links. So the app opens that page, and the web folder
itself is untouched. The Plugin Studio stays inside the app at `studio.html`.
One build, three outputs: the web folder, this Android app, and the desktop app (see the README).

View file

@ -303,13 +303,16 @@ Contracts and gotchas (PROFILES shape, tag-vs-bundle names, `--no-token`):
**Mobile is separate from `bun run build`.** The default build produces **only** the `dx-maps`
web folder — the embeddable maps (and the SPA, if configured). It never touches Capacitor. The
Android/iOS app is an **opt-in** step that *reuses* that web build: Capacitor's `webDir` points at
`public/` (`appId: life.doxa.maps`, `appName: "Doxa Maps"`), so `cap sync` wraps the already-built
`public/dx-maps/` (`appId: life.doxa.maps`, `appName: "Doxa Maps"`), so `cap sync` wraps the already-built
folder into a native app. You never need to build mobile to ship the web maps.
**The quickest app is one plugin, with no rebuild:** `bun run phone:deep-research` installs Deep Research
as an app. The whole tutorial: [how to build mobile apps from plugins](how-to-build-mobile-apps-from-plugins/README.md).
**Android** (project is committed; needs Android Studio + a JDK):
```bash
bun run apk:debug # build web → cap sync → verify → assemble a debug APK
bun run apk:debug # build web → cap sync → verify → assemble a debug APK (the full build asks for BUILD_ALLOW=1)
bun run apk:install # adb install onto a connected device/emulator
```

View file

@ -26,7 +26,11 @@
* verified means there is no second copy of that hash to drift. Only the relative depth changes.
*
* Usage: node internal/set-app-start-page.mjs [--check]
* --check assert the app root is already the UPG-100 page; do not write. Exit 1 if not.
* --check assert the app root is already the start page; do not write. Exit 1 if not.
* APP_START=<bundle>/<profile>[/<preset>] start on another plugin page instead of the UPG-100 list,
* as the tree lays it out: APP_START=doxa-maps-spa/doxa-maps-spa/app is Deep Research
* (`bun run phone:deep-research`). Unset, the app opens the UPG-100 list, as before.
* How-to: documentation/how-to-build-mobile-apps-from-plugins/README.md
*/
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
@ -39,13 +43,21 @@ const CHECK = process.argv.includes('--check')
// The assets tree `cap sync` writes. NOT public/ — see the header.
const APP_PUBLIC = join(ROOT, 'deployment/capacitor/android/app/src/main/assets/public')
const APP_ROOT = join(APP_PUBLIC, 'index.html')
// The showcase leaf, inside the served tree.
// The showcase leaf, inside the served tree: the UPG-100 list unless APP_START names another page.
const TREE = TREE_NAME
const LEAF_REL = `my-upg-100-list/upg-100-mfe/index.html` // the app's web root IS the tree (webDir public/<tree>)
const DEFAULT_START = 'my-upg-100-list/upg-100-mfe'
const START = (process.env.APP_START || DEFAULT_START).trim().replace(/^\/+|\/+$/g, '')
if (!/^[a-z0-9_-]+(\/[a-z0-9_-]+){1,2}$/i.test(START)) {
console.error(`APP-START-PAGE: FAIL — APP_START must be <bundle>/<profile>[/<preset>], got "${START}".`)
process.exit(1)
}
const NAME = START === DEFAULT_START ? 'the UPG-100 list' : START
const LEAF_REL = `${START}/index.html` // the app's web root IS the tree (webDir public/<tree>)
const LEAF = join(APP_PUBLIC, LEAF_REL)
// A marker so --check can tell the app page apart from the studio page without parsing HTML.
const MARKER = `<!-- ${TREE_NAME}: mobile app start page (UPG-100 list) -->`
// The default keeps its original text, so an app built before APP_START existed still checks.
const MARKER = `<!-- ${TREE_NAME}: mobile app start page (${START === DEFAULT_START ? 'UPG-100 list' : START}) -->`
if (!existsSync(APP_PUBLIC)) {
console.log('APP-START-PAGE: SKIP — no synced assets tree.')
@ -62,23 +74,25 @@ if (!existsSync(LEAF)) {
if (CHECK) {
const cur = existsSync(APP_ROOT) ? readFileSync(APP_ROOT, 'utf8') : ''
if (cur.includes(MARKER)) {
console.log('APP-START-PAGE: PASS — the app opens the UPG-100 list.')
console.log(`APP-START-PAGE: PASS — the app opens ${NAME}.`)
process.exit(0)
}
console.error('APP-START-PAGE: FAIL — the app root is NOT the UPG-100 page.')
console.error(`APP-START-PAGE: FAIL — the app root is NOT ${NAME}.`)
console.error(cur.includes('<plugin-studio')
? ' It mounts <plugin-studio>: a recipient opening the app meets the dev studio.'
: ' No UPG-100 marker found.')
: ` No start-page marker for ${START} found.`)
console.error(' Fix: node internal/set-app-start-page.mjs (after cap:sync).')
process.exit(1)
}
// ── REWRITE THE LEAF FOR ONE LEVEL UP ────────────────────────────────────────
// The leaf lives at <tree>/my-upg-100-list/upg-100-mfe/index.html and reaches its bundle with
// `../my-upg-100-list.js`. At the app root that same relative path resolves ABOVE the web root.
// Every leaf-relative reference gains the leaf's own directory prefix; absolute and protocol URLs
// (the Mapbox CDN tags) are left alone.
const LEAF_DIR = `my-upg-100-list/upg-100-mfe/`
// The leaf lives at <tree>/<START>/index.html (the UPG-100 list: my-upg-100-list/upg-100-mfe/) and
// reaches its bundle with `../<bundle>.js`. At the app root that same relative path resolves ABOVE
// the web root. Every leaf-relative reference gains the leaf's own directory prefix; absolute and
// protocol URLs (the Mapbox CDN tags) are left alone. An inline "assetsBase":"../../assets" is not
// rewritten and needs no rewrite: a path that climbs past the web root stops AT it, and the tree's
// assets/ is there.
const LEAF_DIR = `${START}/`
const html = readFileSync(LEAF, 'utf8')
const rebased = html.replace(
/\b(src|href)=(["'])(?!https?:|data:|#|\/)([^"']+)\2/g,
@ -110,7 +124,7 @@ const STUDIO_KEEP = join(APP_PUBLIC, 'studio.html')
if (existsSync(APP_ROOT) && !existsSync(STUDIO_KEEP)) copyFileSync(APP_ROOT, STUDIO_KEEP)
writeFileSync(APP_ROOT, rebased.replace(/<head>/i, `<head>\n ${MARKER}`))
console.log('APP-START-PAGE: the app now opens the UPG-100 list.')
console.log(`APP-START-PAGE: the app now opens ${NAME}.`)
console.log(` from ${LEAF_REL}`)
console.log(` wrote assets/public/index.html (${referenced.length} reference(s) rebased, all present)`)
console.log(' the studio page is kept at assets/public/studio.html')

View file

@ -351,6 +351,8 @@
"packet:ci": "node internal/build-packet-fixtures.mjs",
"packets": "cd library/scripts/posters && VITE_API_BASE_URL=https://pray.doxa.life node packet.mjs",
"phone": "bun run apk:debug && bun run apk:install",
"phone:deep-research": "APP_START=doxa-maps-spa/doxa-maps-spa/app bun run phone:from-tree",
"phone:from-tree": "bun run cap:sync && bun run app:start-page && bun run cap:verify && cd deployment/capacitor/android && ./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk",
"posters": "cd library/scripts/posters && SPARSE=1 OFFLINE=1 VITE_API_BASE_URL=https://pray.doxa.life FORMAT=3x3 node generate.mjs",
"posters:export": "cd library/scripts/posters && node export-public.mjs",
"posters:gallery": "cd library/scripts/posters && node build-gallery.mjs && node build-hd-gallery.mjs",