Multi-display mini-apps
Render on the passenger screen, drive the cluster, build for cars with 1, 2, or 3 screens. The SDK's display + surface + gesture families end-to-end.
Cars come in different screen counts. A FangChengBao 8 (Leopard 8 in some markets) has three — the IVI you're holding, a passenger screen, and the instrument cluster. A Han / Atto 3 has two. A taxi-spec Yuan Plus has one. Your mini-app should adapt.
The cluster behavior described below — slot numbering, OEM-map contention, the "secondary slot is friendly" finding — is verified on Leopard 8 / FangChengBao 8 (DiLink 5.1 firmware). Other BYD platforms (Han, Yangwang U8) ship the same XDJA composer and behave the same in our experience, but the SDK's display-list output is the source of truth — never assume display IDs are stable across vehicles.
This guide shows you how to:
- Enumerate every display the host knows about
- Render UI on a secondary screen (passenger, cluster)
- Drive apps already running on a screen via gesture injection
- Detect which cluster slot the OEM gave you (id=4 vs id=5 on BYD vehicles) and pick the right one
- Adapt gracefully when a screen isn't available
The complete reference apps live at
examples/cluster-hello-world
and
examples/cluster-remote.
The display landscape
client.display.list() returns every addressable display, plus the
active vehicle context when the host emits one (1.6+). On a typical
BYD FangChengBao 8 you get five display entries:
const { displays } = await client.display.list();
// [
// { id: 0, name: 'ivi', isDefault: true, role: 'ivi', isCluster: false, ... },
// { id: 2, name: 'fse', isDefault: false, role: 'passenger', isCluster: false, ... },
// { id: 3, name: 'fission_bg_XDJAScreenProjection', isDefault: false, role: 'cluster', isCluster: true, hidden: true, ... },
// { id: 4, name: 'shared_fission_bg_XDJAScreenProjection_0', isDefault: false, role: 'cluster', isCluster: true, hidden: true, ... },
// { id: 5, name: 'shared_fission_bg_XDJAScreenProjection_1', isDefault: false, role: 'cluster', isCluster: true, overrideLabel: 'Driver', ... },
// ]2.0 breaking change: client.display.list() resolves to
{ displays, vehicle? } instead of a bare DisplaySnapshot[]. Migration is
one rename per call site:
- const displays = await client.display.list();
+ const { displays } = await client.display.list();The vehicle block is undefined on hosts < 1.6 — code that only reads
displays keeps working after the rename without any vehicle handling.
What each one means:
name pattern | role | Render? | Recommended use |
|---|---|---|---|
ivi | 'ivi' | yes (default) | Your normal mini-app UI |
fse | 'passenger' | yes | Co-driver content |
fission_bg_* | 'cluster' | (drops) | Skip |
shared_fission_bg_..._0 | 'cluster' | yes, but contested | Approach with care |
shared_fission_bg_..._1 | 'cluster' | yes, holds | Default cluster target |
Prefer role over isCluster, added in SDK 1.6.0. The legacy boolean
only distinguishes cluster from "everything else", so the IVI and the
passenger screen look the same. role is the field the host gates
pkg.launch / pkg.launchCluster against — what you see here is what the
launch path accepts. isCluster stays for backward compatibility with Phase-A
SDK clients.
Two-screen cars (Han, Atto 3) typically expose ivi + fse only.
One-screen cars expose only ivi.
Vehicle context — same call
display.list()'s response also carries a vehicle block — this
is the host's resolved view of which car you're on and what it
can do. Added incrementally: variantId / friendlyName /
dilinkFamily since SDK 1.6, capabilityBits / capabilities
since SDK 1.7, subTrim / isFallback / fallbackReason since
SDK 1.8.
const r = await client.display.list();
// r.displays → [{ id, role, ... }, ...]
// r.vehicle = {
// dilinkFamily: 'di5.0',
// variantId: 'l5',
// subTrim: 'flagship',
// friendlyName: 'Leopard 5 Flagship',
// capabilityBits: 0b…,
// capabilities: ['display.read', 'pkg.read', 'pkg.launch.dishare', ...],
// isFallback: false,
// fallbackReason: null,
// }Use the bitmask + readable list to pre-empt actions the car can't fulfil, before the user taps:
const haveCluster = r.vehicle.capabilities.includes("surface.write.cluster");
clusterButton.disabled = !haveCluster;
clusterButton.title = haveCluster
? `Open on cluster (${r.vehicle.friendlyName})`
: `Cluster pixels not available on ${r.vehicle.friendlyName}`;When r.vehicle.isFallback === true, the host fell back to a
sub-trim aggregate / DiLink-default row because the precise
fingerprint hasn't been probed yet. Treat the cap set as
best-effort — render the action enabled but consider tagging the
result with a soft "experimental" hint so the user knows. The
capability-detection guide
covers the fallback semantics in full.
Step 1 — Enumerate displays
Mini-apps run with full host capability — there's no manifest-side permission to declare. Just call the bridge:
import { MiniAppClient } from "i99dash";
const client = MiniAppClient.fromWindow();
// One-shot snapshot
const { displays, vehicle } = await client.display.list();
// Or subscribe to hot-plug events (rare, but happens when an
// external HUD module wakes up)
const off = await client.display.onChange((evt) => {
if (evt.type === "snapshot" || evt.type === "added") {
rerenderTargetDropdown(evt.displays ?? []);
}
});
// ... later
off();The list reflects what the OEM exposes via Android's DisplayManager.
Don't hardcode display IDs (they're not stable across vehicles —
your FangChengBao 8 might number them differently from a customer's
Han, and even the same head unit can renumber after a firmware update).
Step 2 — Pick the right secondary screen
The display.list() snapshot tells you what's available. The right
choice depends on what you're trying to do:
Passenger screen
Look for role === 'passenger'. This is the safest secondary
target — no z-order fight with OEM apps.
const passenger = displays.find((d) => d.role === "passenger");
if (!passenger) {
// Some cars (Han, Atto 3 base) don't have a passenger screen —
// fall back to keeping the UI on the IVI.
return;
}
const sfc = await client.surface.create({
displayId: passenger.id,
route: "/passenger.html",
});Cluster (driver gauge area)
Two cluster slots, very different behavior — verified on Leopard 8:
const clusters = displays.filter((d) => d.role === "cluster");
// Prefer the secondary overlay slot (`..._1`) — OEM apps don't
// fight us there. The primary slot (`..._0`) is where the OEM map
// renders aggressively; landing there means you'll either flicker
// or need the host's force-stop watchdog (which disrupts nav).
const target =
clusters.find((d) => /XDJAScreenProjection_1$/i.test(d.name)) ?? clusters[0];
const sfc = await client.surface.create({
displayId: target.id,
route: "/cluster.html",
});The XDJAScreenProjection_1 heuristic matches BYD's naming on
FangChengBao 8 / Yangwang U8 / Denza N7. Other OEM platforms (XPeng,
Li Auto) expose different display names; if your mini-app targets
multiple vendors, prefer enumeration + role === 'cluster' over
name regex.
Step 3 — Mount your bundle on the target
The surface family opens a sandboxed WebView on the target display
and loads a route inside your bundle:
const sfc = await client.surface.create({
displayId: 5,
route: "/cluster.html", // resolved relative to your bundle root
});
console.log(sfc);
// {
// surfaceId: 'sfc_e2e...',
// path: 'am-start' | 'presentation' | 'overlay',
// displayId: 5,
// route: '/cluster.html'
// }path tells you which mounting strategy the host picked:
'presentation'—Presentation.show()worked. Cleanest path; the host owns the dialog window's lifecycle. Used on non-virtualized secondary displays.'overlay'—WindowManager.addViewwithTYPE_APPLICATION_OVERLAY. Used when Presentation is denied (e.g. virtualized cluster displays). Same security posture; same WebView API.'am-start'— A realActivitylaunched onto the display via loopback ADB. Required on BYD's XDJA-virtualized cluster displays where the other two paths are silently dropped by the composer. The activity's content is identical to the other paths; only the launch mechanism differs.
You don't need to branch on path — your mini-app's cluster.html
runs identically across all three. The field is exposed for
diagnostics + future SDK helpers.
Step 4 — Update or close the surface
// Navigate the surface to a different route in the same bundle
await client.surface.navigate({
surfaceId: sfc.surfaceId,
route: "/cluster-night.html",
});
// Close it
await client.surface.destroy({ surfaceId: sfc.surfaceId });Surfaces are ref-counted by the host. If your mini-app's WebView
tears down (user navigated away), the host auto-destroys all
surfaces it opened — you don't need a beforeunload cleanup.
Step 5 — Launch an installed app on the cluster
Phase C added a pkg family — your mini-app can enumerate installed
apps and launch them on any display, including the cluster:
const cluster = (await client.display.list()).displays.find(
(d) => d.role === "cluster" && /XDJAScreenProjection_1$/i.test(d.name),
);
if (!cluster) throw new Error("no cluster on this car");
// Launch a package onto the cluster's friendly slot (`..._1`).
// Standard `client.pkg.launch` would reject this with
// `error: 'role:requires_cluster_op'` — clusters always need
// the dedicated launch op.
const r = await client.pkg.launchCluster("com.kakiradios.world", {
displayId: cluster.id,
});
console.log(r); // { ok: true, path: 'am-start', displayId: 5 }
// Tear it down later (no separate `pkg.stop` scope; same
// `pkg.launch` you used to start it covers the inverse):
await client.pkg.stop("com.kakiradios.world");Use pkg.moveCluster when
the package is already running on the IVI and you want to slide it
onto the cluster (rather than starting a fresh instance). The
pkg-launcher
example app shows the full flow — search bar, card grid, per-display
launch chips, plus a touchpad that drives the launched app.
Driver-eyeline panel — Song Plus, L7, HAN L
Some BYD trims don't ship an addressable instrument cluster (no XDJA
_1 overlay) but do expose a wide passenger panel that physically
sits in the driver's eyeline. From the user's seat, that panel is
the driver display — even though Android sees it as role: 'passenger'.
The host advertises this via the reserved overrideLabel value
'Driver', applied to the passenger Display by the active
VehicleProfile. Mini-apps that want
to render "for the driver" should target this label rather than
hardcoding role === 'cluster' — same intent, different display
class depending on trim.
Resolution priority for "open on the screen the driver looks at":
const r = await client.display.list();
// 1. Real cluster overlay (L8 / L5 Lidar XDJA `_1` slot).
// Use pkg.launchCluster — `pkg.launch` rejects cluster ids.
const cluster = r.displays.find(
(d) => d.role === "cluster" && /XDJAScreenProjection_1$/i.test(d.name),
);
// 2. Passenger panel labeled "Driver" by the active VehicleProfile
// (Song Plus, L7, HAN L). Use pkg.launch (passenger scope).
const driverPanel = r.displays.find(
(d) => d.role === "passenger" && d.overrideLabel === "Driver",
);
if (cluster) {
await client.pkg.launchCluster("com.byd.maps", { displayId: cluster.id });
} else if (driverPanel) {
await client.pkg.launch("com.byd.maps", { displayId: driverPanel.id });
} else {
// L5 / L5U / Generic without a Driver-labeled panel — no
// driver-eyeline target on this trim.
}The trim matrix today (will grow as more profiles are added):
| Trim | Driver target | Mechanism | Capability |
|---|---|---|---|
| L8 / L5 Lidar | Cluster id 5 | pkg.launchCluster | pkg.launch.cluster.pixel |
| Song Plus / L7 / HAN L | Passenger panel id | pkg.launch | pkg.launch.passenger |
| L5 / L5U | none | — | — |
A few caveats:
'Driver'is a reserved label. It's enumerated inRESERVED_OVERRIDE_LABELSon the SDK side; a CI drift script (scripts/check-driver-label-contract.mjs) fails the build when the host emits a label not in the enum. Adding a new reserved label is a coordinated SDK + host + docs change — don't invent strings client-side.- Cluster wins when both exist. If a future trim ever ships
both a real cluster and a passenger panel labeled
'Driver', the cluster is the right target — the passenger panel is the fallback for trims that don't have one. Code the priority as shown above, not asif (driverPanel) ... else if (cluster). - Surface a soft notice when you fall back. The
pkg-launcherexample does this — when it lands an app on the panel instead of a cluster, it toasts a "no cluster on this trim" message so the user knows they're not on the literal instrument cluster.
The
pkg-launcher
example codifies this in a single resolveDriverTarget() helper —
copy the pattern verbatim if you need the same intent in your own
mini-app.
L5 / Di5.0 — passenger via DiShare
On Leopard 5 / Leopard 5 Ultra (Di5.0), the passenger panel is
not an addressable Android Display — it's reachable only
through BYD's DiShare mirror chain. So display.list() returns
no entry with role === 'passenger' to feed into displayId,
and a bare pkg.launch({displayId: ...}) has nothing to bind to.
The host advertises this trim shape via the
pkg.launch.dishare
capability bit. When you see it in display.list().vehicle.capabilities
and there's no passenger Display, pass the targetRole: 'passenger'
hint instead — the host commits the cast over DiShare's API
service, no synthetic gesture required:
const r = await client.display.list();
const passenger = r.displays.find((d) => d.role === "passenger");
const hasDishare = r.vehicle.capabilities.includes("pkg.launch.dishare");
let result;
if (passenger) {
// L8 / L5L (Di5.1): real Display, normal am-start path.
result = await client.pkg.launch("com.kakiradios.world", {
displayId: passenger.id,
});
} else if (hasDishare) {
// L5 / L5U (Di5.0): no Display, DiShare mirror chain.
result = await client.pkg.launch("com.kakiradios.world", {
targetRole: "passenger",
});
} else {
// L7 / HAN L / Generic: no passenger panel reachable.
return;
}
console.log(result);
// { ok: true, path: 'am-start' } on L8 / L5L
// { ok: true, path: 'dishare-cast' } on L5 / L5UA few honest caveats baked into DiShare itself:
- It's a live mirror, not a multi-display projector. While the cast is active, the IVI also shows the same content. The user must accept this — the host can't show a different IVI app while the panel mirrors something else.
- The trim must really be Di5.0. The host's
targetRolebranch only fires when the active vehicle's bits includepkg.launch.dishare; on every other trim the parameter is silently ignored and the request falls through to the standarddisplayIdpath. Cross-trim code that always passes the hint is safe but noisy. - No partial recovery. If the bind / register / arm step fails
inside DiShare, you get
path: 'dishare-denied'with a typederror(bind_failed,register_failed,dishare_not_installed,a11y_not_attached). Render a fallback panel — there's no retry-with-displayId because there is no displayId on this trim.
Step 6 — Drive cluster apps via gesture injection
Pixel rendering on the cluster is constrained on most BYD vehicles
(see Cluster pixel limits below). The
realistic capability is input injection — synthesize taps and
swipes on whatever's already running on the cluster (the OEM theme,
nav, the app you just pkg.launchCluster'd):
import { MiniAppClient } from "i99dash";
const client = MiniAppClient.fromWindow();
const { displays } = await client.display.list();
const cluster = displays.find((d) => d.role === "cluster");
if (!cluster) throw new Error("no cluster on this car");
// Tap — fires onto a target display at given coords
await client.gesture.tap({
displayId: cluster.id,
x: 960,
y: 360,
});
// Swipe
await client.gesture.swipe({
displayId: cluster.id,
fromX: 100,
fromY: 360,
toX: 1820,
toY: 360,
durationMs: 300,
});
// Long press
await client.gesture.longPress({
displayId: cluster.id,
x: 960,
y: 360,
durationMs: 800,
});The host implements gesture.dispatch via
AccessibilityService.dispatchGesture(displayId), falling back to
am input -d N tap X Y over loopback ADB on devices where the
accessibility path is denied. Both routes work without vendor signing.
Launch display ≠ input display on Leopard 8 / FangChengBao 8.
When you pkg.launchCluster({displayId: 5}), ActivityManager
places the activity on logical display 5, but XDJA's compositor
projects the activity's surface onto display 3 — and
InputDispatcher follows the surface, not the activity binding.
A dumpsys input after launch confirms the launched app's window
sits at displayId=3 while display 5 has zero windows in the
input system.
What this means for the cursor + gesture pair:
const target = (await client.display.list()).displays.find(
(d) => d.role === "cluster" && /XDJAScreenProjection_1$/i.test(d.name),
); // id=5
const base = (await client.display.list()).displays.find(
(d) =>
d.role === "cluster" &&
/^fission_bg_/i.test(d.name) &&
!/_\d+$/.test(d.name),
); // id=3
// Launch — use the FRIENDLY slot (display 5).
await client.pkg.launchCluster(pkg, { displayId: target.id });
// Cursor + gestures — dispatch to the BASE slot (display 3) where
// the input window actually lives.
const handle = await client.cursor.attach({
targetDisplayId: base.id,
style: "glow",
});
await client.gesture.tap({ displayId: base.id, x, y });The pkg-launcher
example codifies this split: launches go to display 5, the
touchpad's cursor + gestures go to display 3. The pattern only
shows up on XDJA-virtualized clusters; vehicles with a
non-virtualized passenger display behave normally.
Pair gesture with a cursor for visual feedback
A common pattern: turn the IVI into a touchpad. Your mini-app
captures touch events, draws a cursor on the IVI showing the user
where the eventual tap will land, and fires gesture.tap on
release.
const handle = await client.cursor.attach({
targetDisplayId: cluster.id,
style: "glow",
});
iviCanvas.addEventListener("pointermove", (e) => {
// Hot-path — fire and forget at 60 Hz. The host has a 5-second
// gate-bypass for cursor.move so this doesn't burn the cap store.
handle.move(e.clientX, e.clientY);
});
iviCanvas.addEventListener("pointerup", async (e) => {
// Translate IVI coords to cluster coords (1920×720 on FangChengBao 8)
const rect = iviCanvas.getBoundingClientRect();
const fx = (e.clientX - rect.left) / rect.width;
const fy = (e.clientY - rect.top) / rect.height;
await client.gesture.tap({
displayId: cluster.id,
x: Math.round(fx * 1920),
y: Math.round(fy * 720),
});
});
// On unmount
await handle.detach();The cursor view is on the IVI as a touchpad-style indicator —
not on the cluster (drawing on the cluster is signature-gated).
targetDisplayId is metadata the host stamps so your coord
translation matches the gesture target.
Adapting to 1 / 2 / 3 screen cars
Don't assume a screen exists. Always check the enumeration result and degrade gracefully:
async function pickTargets() {
const { displays } = await client.display.list();
const ivi = displays.find((d) => d.role === "ivi"); // always present
const passenger = displays.find((d) => d.role === "passenger");
const cluster = displays
.filter((d) => d.role === "cluster")
.find((d) => /XDJAScreenProjection_1$/i.test(d.name));
return {
ivi,
hasPassenger: !!passenger,
hasCluster: !!cluster,
passengerId: passenger?.id,
clusterId: cluster?.id,
};
}
const t = await pickTargets();
if (t.hasCluster) {
showClusterRemoteUI();
} else if (t.hasPassenger) {
showPassengerOnlyUI();
} else {
// Single-screen car — IVI only. Don't render features that
// require a secondary display.
showSingleScreenUI();
}Common car shapes:
| Vehicle | Screens | What's available |
|---|---|---|
| FangChengBao 8 / Yangwang | IVI + cluster + FSE | All three families |
| Han / Atto 3 | IVI + cluster | Cluster gesture; no passenger |
| Yuan Plus base | IVI | IVI rendering only |
Cluster pixel limits
The cluster MCU on most BYD platforms (FangChengBao 8 verified) composites three layers in fixed Z-order:
- Background — XDJA virtual display 3 (
fission_bg_*) - Gauges — MCU-rendered, untouchable
- Overlay — XDJA virtual displays 4 + 5 (
shared_fission_bg_..._0/1)
Your surface.create content lands in the overlay layer. Three
caveats:
- The gauges themselves can't be replaced without a vendor signature. The speedometer, tachometer, gear indicator — all MCU code paths.
- Display 4 (
..._0) is contested with the OEM map app. Your content paints there but the OEM map z-orders above. The host ships a force-stop watchdog that evicts the OEM map every 2s, but this disrupts navigation — only use display 4 when fully replacing the cluster overlay is the explicit product call. - Display 5 (
..._1) is the friendly slot — BYD's secondary overlay, rarely contested by OEM apps, content holds without disruption. Default to this on Leopard 8 / FangChengBao 8. Other BYD platforms expose the same..._1naming; theXDJAScreenProjection_1$regex above handles them all.
Common patterns
Passenger-screen content card
A music app or trip-info card the front passenger interacts with while the driver focuses on the road:
const { displays } = await client.display.list();
const passenger = displays.find((d) => d.role === "passenger");
if (passenger) {
await client.surface.create({
displayId: passenger.id,
route: "/passenger.html",
});
}Cluster information widget
A small status widget on the cluster — trip stats, EV state-of-charge, ambient air quality. Use the friendly slot:
const { displays } = await client.display.list();
const cluster =
displays.find((d) => /XDJAScreenProjection_1$/i.test(d.name)) ??
displays.find((d) => d.role === "cluster");
if (cluster) {
await client.surface.create({
displayId: cluster.id,
route: "/cluster-widget.html",
});
}Cluster app launcher
Pick a package and start it on the cluster, with cleanup:
const apps = await client.pkg.list();
const cluster = (await client.display.list()).displays.find(
(d) => d.role === "cluster" && /XDJAScreenProjection_1$/i.test(d.name),
);
if (cluster) {
await client.pkg.launchCluster("com.byd.maps", { displayId: cluster.id });
// ...later
await client.pkg.stop("com.byd.maps");
}Cluster remote control
The IVI as a touchpad driving the cluster's existing apps. Note
the launch-vs-input display split callout above — cursor.attach +
gesture.tap target the base slot (display 3 on Leopard 8),
not the slot you launched into:
// See examples/pkg-launcher for the full implementation incl. the
// LAUNCH=5 / CONTROL=3 display split.See also
DisplayController/DisplaySnapshot—rolefieldPkgController—launch/launchCluster/move/moveCluster/stopCursorController/GestureControllerpkg-launcherexample — full reference: search, card grid, per-display launch chips, touchpad with the launch-vs-control display split- Cluster z-order — host-side mechanics — what the host does to fight (or not fight) the OEM map
Build for L5 + L8
One bundle that runs on every BYD trim — classic IIFE, low minHostVersion, the SDK, feature detection, and a graceful fallback.
Location + heading
How to source position + heading in v5 — the per-family `client.location` controller was removed; the unified `client.car` catalog will expose location signals in v5.1. Until then, use `navigator.geolocation` directly. Plus a heading/compass recipe.