@lemoncloud/hybrid-shell
v0.5.0
Published
A virtual mobile shell for developing and testing hybrid (WebView) apps in a desktop browser.
Downloads
979
Readme
hybrid-shell
A virtual mobile shell for hybrid (WebView) apps. It runs your web app inside a phone frame in a desktop browser and plays the native side of the bridge, so you can develop and test the web half of a hybrid app without Xcode, the Android SDK, or a device.
Status: early development (
0.1.0). Expect breaking changes before1.0.
Derived from the dou-app virtual shell (apps/virtual-shell and libs/fake-shell, ADR 0208),
copied without its git history.
What is here today
defineShell()andloadConfig()— the app config (hybrid-shell.config.ts), loaded with jiti so the config can be TypeScript with no build step.- A canonical vocabulary — 14 shell → web events and the web → shell requests (
CANONICAL_EVENTS,CANONICAL_REQUESTS) — that the API, logs and scenarios use; each app maps it to its own names. - Protocol presets:
native-host({ kind, id, method, params }envelopes,__<APP>_*globals,<app>-native-messageevents) andapp-bridge-host(typed messages with aWebAppReadyhandshake). A bridge of typed messages —{ type, data }or flat{ type, ... }, with an id or answered by type name (RequestDeviceInfo→DeviceInfo) — is described as data withtypedMessage(spec), which returns a preset; seeexamples/typed-message. An app that fits none of them passes its ownProtocolPresetobject. A web app with no native shell at all usesweb: no globals, no bridge, only the events a browser carries — see the web preset. createBridge()— answers the web's requests with your handler module, else the core default for the canonical request, else the preset's refusal (at once, so the web never waits on retries).@lemoncloud/hybrid-shell/fake-shell—FakeShell, a Playwright helper that plays the native side in an E2E test on the same preset and bridge:install(page, { config }),emit(event, payload),handle(method, handler),received(),waitFor(),unhandled.- Environment simulation, installed in the frame before the web's first script: the network
(
offline,slow— a wait before the answer, or a body handed on atbytesPerSecor a3g/2gpreset so a progress bar shows each step —flaky, URL rules;fetch, XHR, WebSocket and EventSource all see it, and every request lands in anetlog line — a slowed one when its last byte arrives, so itsmsis the whole download), the sockets on their own (net.ws.drop·stall(half-open: messages vanish, no close) ·inject·lag·resume·list·messages, in the same rule list and cleared bynet.clear(); each message kept by its size and, for text, its first 160 characters with the values under secret-named keys, JWTs and bearer tokens masked, in the socket's own ring of the last 200 apart from the log — never in an export or a recording —, withlist()counting them all), app lifecycle (foreground, background, cold and warm restart, reload, crash, screen lock, low memory), seven device presets with rotation and safe areas — the iPhone Duo among them, closed, half-open or open (device.pose), with its sizes and insets measured on the iOS 27.1 simulator: unfolding turns the page (upright closed, on its side open), half-open folds it across its long side, the page is resized, never relaunched, and an app that maps thefoldevent hears{ posture, segments }(the areas either side of the fold) and finds it indevice.info; a keyboard the new screen has no room for, after a pose or a turn, is put away —, locale, time zone, theme, keyboard, geolocation, clipboard, permissions and the requests a person answers (alerts, share sheets, external links, permission prompts) as pending cards. Events sent before the web listens are held, so a cold start's push or link is the first thing it hears. - Situations (
recipe.run(id)): seven named bundles of tools — opened from a notification while closed, subway (offline, back online with five seconds to reconnect, then the sockets alone cut, then a 2G trickle), English phone in dark mode, … — that a config extends withrecipes. A situation that needs an event or request the app does not map is dimmed, with the missing name under it; it is never hidden.recipe.stop()ends the running one before its next step (its result isSTOPPED; the phone stays as the steps so far left it) — on the page, pressing the running situation again. - The shell page, built as static files into
dist/ui/: situations, the scenario panel and one fold of raw tools (the network among them, with a search box) on the left, the phone in the middle — under it the simulator's toolbar: Home (the app goes behind a home screen whose icon brings it back), Screenshot and Record (the browser captures the tab, after asking, cropped to the screen where it can, saved as PNG and WebM — MP4 where the browser records only that), Rotate, and for a phone that folds Closed, Half open and Open; a phone that folds draws its fold and hinge —, the state (with the open sockets, each with Drop and Stall), pending cards and a plain-words timeline on the right. The edges between them drag to resize (or ← → on a focused edge; Enter folds a side, a double click puts it back), and the widths are kept in that browser; the sides fold into drawers at 900px and under. A four-line getting-started card sits beside the phone when the centre has room, and is a one-line strip above it (its steps a press away) when not — it never covers the app — until it is closed once. A situation's outcome stays under its button until the next one; saving a recording says the file's name and ends it. The page holds what a person looks at and presses; what only an agent uses (YAML text, the log as a file, cards for unhandled requests) is the API's. Every button calls the same function an agent reaches throughwindow.virtualShell. - One timeline of four sources —
web(the page's console), the bridge (web→shell,shell→web, refused requests; filter them together asbridge),net, andagent: every tool call with who made it, situations betweenrecipe:<id>andrecipe:<id> done,virtualShell.note(text), and what a person does in the frame (taps with where the finger went down, typing once per field, Enter, submits) with the control's test id, role, name and text. A password, one-time-code or card field — and any field matching the config'sredact.inputsselectors (passed to the page once the shell server lands, likerecipesanddevices) — is recorded as{{secret.<name>}}; its text is never read.virtualShell.logs.export({ format: 'json' | 'md', since })gives it as text to attach to a report. On the page the timeline shows one kind at a time when asked (app — the bridge and the console —, network with the sockets' messages, tools and runs, performance, shell), folds the same line repeated into one with a count, folds a console line the app also forwards over its bridge (awebLog) into that line with a mark, and opens a line to the data it carries, to copy. Scrolled down to read, it holds still and counts the new lines above. A scenario's passing steps are in the scenario panel (and the tools view), not repeated in the others; a notification the app got while away is a shell line with its own Tap. - Made for an agent to read: every line has a
level(logs({ level: 'warn' })is warnings and errors); an error the page did not catch and a promise nobody handled areweberrors with their stack; a request's line comes when its body ends, withttfbMs,ms,bytes(nullwhen nothing says) andcached; a bridge reply carries thelatencyMsthe app waited; a path change is arouteline timed to the next paint. Aperfsource in its own ring of 500 (logs.perf(), off withshell.perf: false) has the load (FCP, LCP, DOMContentLoaded, load), long tasks, the heap every 10 seconds and dropped frames.virtualShell.mark(name)andspan(name).end()writemark:andspan:lines, and so does the app throughwindow.__hybridShell.logs.summary({ since })counts it all up — errors by message, the slowest requests and replies, refusals, load, long tasks, heap, routes, sockets — andlogs.export({ format: 'md', budget })puts what matters first and folds the rest into one line. - Scenarios (schema) — YAML files of steps: a tool call (
push.tap: { … }), a situation (recipe: subway), a screen step (tap— at{ x, y }or at a target's centre or a corner, as a finger: pointer, touch, then mouse events and the click, on whatever is at that point —click,type,press,scroll,submit, each finding its control by showntext,role+name, ortestid; a target two controls match fails as ambiguous),wait,note, and eight checks (expect.event,.path,.text,.notText,.net— a request, or a socket'sopen·close·message·reopen—.hitArea— big enough to press and not covered —.status,.screenshot).virtualShell.scenario.run(yaml, { vars, secrets })runs one in the page and answers{ name, status, steps: [{ n, step, status, ms, detail }], log }; a scenario whoserequiresthe app does not offer is skipped, and a prod shell refuses any scenario not markedprod: safe.{{secret.<key>}}is filled fromsecretsat thetypestep only; the result (its log included) and the runner's own lines carry the placeholder, never the value. The scenario panel asks for each secret an opened file needs, for that run only, and lists the config'sscenariosfolder: picking a file loads it.scenario.stop()stops the running one andscenario.last()is the last run; the scenario panel runs (and stops) a picked or opened one and shows every run step by step, and a failed step opens its line in the timeline. - Recording:
virtualShell.logs.toScenario({ since, name, state })turns a stretch of the log into a scenario draft — one step per tool call, a situation as one step, a person's input as screen steps — a tap by the first selector that matched only that control, with# observed: tap at x,y, or by its point when none did — what the app did as# observed:comments (neverexpect: a recording of a bug does not know the right answer), repeated ids asvars, and astateblock with the phone it started from. The timeline's "Start recording" and "Save as scenario file" download the same text as<name>.scenario.yaml; "Open a scenario file" in the scenario panel makes itsstateand runs it.
examples/ has a fake web per preset with an integration test each — globals, one
request, one event, storage restored after a relaunch — and one scenario each in the virtual shell:
offline → fail → recover, background → cold push tap → the linked screen, camera denied → app
settings. typed-message has two webs, one per envelope shape; the flat one only listens. Three
also have a YAML scenario under scenarios/ — two pass, and
typed-message/data's fails on purpose on an
ambiguous target; its socket-reconnect drops the
web's socket and waits for it to come back. examples/csp is a web served with a Content Security Policy, which forbids
the frame's own eval the runtime is installed with; the shell server's proxy strips it.
examples/web is a web app with no native shell, once on Vite and once on Angular's
ng serve, with the shared deep-link, offline-feed, push-opens-screen and tap-hit-area scenarios.
Running it
Try it with no install, against a web dev server that is already running:
npx @lemoncloud/hybrid-shell --web http://localhost:4700 --app myappIt prints the shell's address (the web's port + 4, e.g. http://localhost:4704/__shell/). --app
is the name the web looks for in its globals and event names; --preset defaults to
native-host, whose starter method and event names are assumed. --handlers <file> answers app-only
methods and --scenarios <dir> is what scenario.list() shows. A web app with no native shell
needs no --app: --preset web (the web preset). The panel's "Save as config file"
(or hybrid-shell init --web … --app …) writes hybrid-shell.config.ts from those flags, never
over an existing file.
Or let it read the app's repository and draft the config:
npx @lemoncloud/hybrid-shell init --scan --dry-run # what it found, step by step; writes nothing
npx @lemoncloud/hybrid-shell init --scan # hybrid-shell.config.ts + apps/shell-adapter/src/handlers.tsIt reads source files only: the envelope the web posts (ReactNativeWebView.postMessage(…)) picks
the preset — with no ReactNativeWebView, onMessage or injectedJavaScript anywhere, web — a typed one gets a typedMessage({ … }) draft with its channel, globals and
Request<X> → <X> pairing — then the globals the native app sets, the names the web listens for
and sends (its bridge type tables included, the native message handler's too), the canonical
mapping, and the web's port and start command. Env files are listed by name and never opened. A
canonical name matched by a word rather than the starter's own name is marked // AMBIGUOUS:,
what it could not find // TODO:; yarn local --check counts what is left. A canonical name it
left out is not offered, and the panel dims the situations that need it, saying which name is
missing. For a typed bridge it reads the native handler's case 'X': blocks for the type each
request is answered with (and failed with), and the web's { type, data } union for that answer's
shape: each app-only request gets a stub answer of that shape, marked // TODO:. A Google
sign-in request is drafted as auth: oauth-popup, another provider's as 'unsupported'. The
handler module lists the app's remaining names for the person writing their answers;
sampleImage() is a picture to answer a camera or photo picker with, once the handler has asked
shell.permissions.request(kind) as the app does.
Installed, with a config file:
yarn add -D @lemoncloud/hybrid-shell # "local": "hybrid-shell local" in package.json scripts
yarn local --check # env files per env.source, free ports, an installed core behind npm — reports and exits
yarn local [--env prod] # starts web.start, serves the shell, stops both on Ctrl-C
yarn local --mode web # the web dev server alone, on the dev backend
yarn local --mode native --platform android # the config's native.android commandThe shell's DEV | PROD switch runs the other backend's web and shell on their own ports (prod: the web's port + 10 and + 14) in the same run, so a session never crosses backends; Ctrl-C stops both. Prod runs in the shell only, where the page shows which backend it is.
A taken port stops the run with its name; the shell never moves to another. Env files are put in
place per env.source (example copies .env.example and names keys left empty, secrets-repo
clones the repository into the user's cache and copies the mapped files, manual says what is
missing); no value is ever printed.
Scenarios from a terminal or CI (needs @playwright/test as a devDependency, plus
npx playwright install chromium):
yarn hybrid-shell run scenarios/*.yaml [--headed] [--out dir] [--var key=value] [--secret key=ENV_NAME]It reuses a shell already running for the same env or starts one for the run, runs each file in
headless Chromium as a touch phone (hasTouch, isMobile — a web that checks ontouchstart takes
its mobile path; a tap presses the same way as from the panel), writes screenshots and results.json to --out (default
hybrid-shell-results), prints the results, and exits 1 if a scenario failed or none ran.
Docs
- Adapter checklist — putting an existing app on the shell, step by step.
- Scenario schema — steps, targets, checks, vars and secrets, the result.
- What the shell imitates — the core defaults an app's own table overrides.
- The web preset — a web app with no native shell: what is there, what is hidden.
- Pitfalls — the quiet failures, numbered as the code cites them.
- Agent procedure template — an app's shell-verify skill, with placeholders.
App config
// hybrid-shell.config.ts
import { defineShell } from '@lemoncloud/hybrid-shell';
import type { NativeEvents, NativeMethods } from './src/native-types';
export default defineShell<NativeMethods, NativeEvents>({
app: { name: 'myapp', displayName: 'My App', scheme: 'myapp' },
preset: 'native-host',
web: { start: 'yarn dev', port: 4700 },
env: { files: ['.env'], source: 'example' },
events: { 'app.state': { name: 'appStateChanged' } },
requests: { 'device.info': 'getDeviceInfo' },
quickLinks: { origin: 'https://app.example.com', screens: [{ id: 'home', path: '/' }] },
});requests values must be names in your method table and events[*].name names in your event
table, so renaming a bridge method breaks the config at compile time. Fake answers for your own
methods go in a handler module (handlers: () => import('./hybrid-shell.handlers')), each checked
against that method's params and result types; 'unsupported' answers with the preset's refusal.
A method no handler answers is refused at once too, with an unanswered line in the bridge log and
a notice on the panel; shell.unanswered: 'ask' puts it on a card to answer by hand instead
(5 seconds, never during a scenario run, never for payments, SMS, stores or push tokens, nor the
names in shell.neverAsk). hybrid-shell local --check counts the requests the web sends that
nothing answers. The shell page opens in the browser's language (Korean for a Korean browser,
English otherwise) and light; shell.ui: { locale: 'ko' | 'en', theme: 'light' | 'dark' |
'system' } sets other defaults, and a pick in the header's Page menu (the page's own language and
colours, apart from the phone's locale and theme) is kept in that browser.
Development
yarn install
yarn test # vitest
yarn typecheck
yarn build # library (dist/*.js, bundled .d.ts) + shell UI (dist/ui)
yarn verify # all of the above + a NodeNext consumer type check + the packed package (test:pack)