i99dash docs
Guides

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:

  1. Enumerate every display the host knows about
  2. Render UI on a secondary screen (passenger, cluster)
  3. Drive apps already running on a screen via gesture injection
  4. Detect which cluster slot the OEM gave you (id=4 vs id=5 on BYD vehicles) and pick the right one
  5. 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 patternroleRender?Recommended use
ivi'ivi'yes (default)Your normal mini-app UI
fse'passenger'yesCo-driver content
fission_bg_*'cluster'(drops)Skip
shared_fission_bg_..._0'cluster'yes, but contestedApproach with care
shared_fission_bg_..._1'cluster'yes, holdsDefault 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.addView with TYPE_APPLICATION_OVERLAY. Used when Presentation is denied (e.g. virtualized cluster displays). Same security posture; same WebView API.
  • 'am-start' — A real Activity launched 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):

TrimDriver targetMechanismCapability
L8 / L5 LidarCluster id 5pkg.launchClusterpkg.launch.cluster.pixel
Song Plus / L7 / HAN LPassenger panel idpkg.launchpkg.launch.passenger
L5 / L5Unone

A few caveats:

  • 'Driver' is a reserved label. It's enumerated in RESERVED_OVERRIDE_LABELS on 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 as if (driverPanel) ... else if (cluster).
  • Surface a soft notice when you fall back. The pkg-launcher example 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 / L5U

A 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 targetRole branch only fires when the active vehicle's bits include pkg.launch.dishare; on every other trim the parameter is silently ignored and the request falls through to the standard displayId path. 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 typed error (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:

VehicleScreensWhat's available
FangChengBao 8 / YangwangIVI + cluster + FSEAll three families
Han / Atto 3IVI + clusterCluster gesture; no passenger
Yuan Plus baseIVIIVI rendering only

Cluster pixel limits

The cluster MCU on most BYD platforms (FangChengBao 8 verified) composites three layers in fixed Z-order:

  1. Background — XDJA virtual display 3 (fission_bg_*)
  2. Gauges — MCU-rendered, untouchable
  3. 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 ..._1 naming; the XDJAScreenProjection_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

On this page