Today WebView Bridge
Contract

Native Dialog

Portable iOS and Android native dialog requests with correlated session results.

nativeDialog.present asks an iOS or Android host to present system-owned dialog UI. It is an additive capability advertised as native_dialog.v1; Web MUST check that capability before offering UI that depends on it.

The contract separates a dialog's semantic variant from its actions and text fields. This keeps one wire shape while covering alerts, confirmations, prompts, and action sheets.

Wire requests

type NativeDialogPresentRequest = {
  type: 'nativeDialog.present'
  schemaVersion: 1
  sessionId: string
  title?: string
  message?: string
  actions: NativeDialogAction[]
} & (
  | { variant: 'alert' }
  | { variant: 'confirm' }
  | { variant: 'prompt'; fields: NativeDialogTextField[] }
  | { variant: 'actionSheet'; anchor?: NativeDialogAnchorRect }
)

interface NativeDialogDismissRequest {
  type: 'nativeDialog.dismiss'
  schemaVersion: 1
  sessionId: string
}

The envelope stays flat: there is no nested payload or dialog wrapper. Titles, messages, action titles, labels, and placeholders are already localized by Web. Native MUST NOT translate or replace them.

Variants

VariantPortable native presentation
alertInformational alert with one to three actions
confirmExactly two actions, exactly one with role cancel
promptAlert with one to three text fields and one to three actions
actionSheetA native action list; optional viewport CSS-pixel anchor for iPad popover UI

All variants require at least one non-empty action and at least one non-empty title or message. Action and field IDs must be non-empty and unique within the request. At most one action may have the cancel role.

interface NativeDialogAction {
  id: string
  title: string
  role?: 'default' | 'cancel' | 'destructive'
  enabled?: boolean
}

interface NativeDialogTextField {
  id: string
  label?: string
  placeholder?: string
  initialValue?: string
  inputMode?: 'text' | 'email' | 'number' | 'decimal' | 'phone' | 'url'
  secure?: boolean
  autocapitalization?: 'none' | 'sentences' | 'words' | 'characters'
}

interface NativeDialogAnchorRect {
  x: number
  y: number
  width: number
  height: number
}

anchor uses viewport CSS pixels from getBoundingClientRect(). iOS converts it into the WKWebView coordinate space before configuring popoverPresentationController.sourceRect. Android may ignore it. If an iPad action sheet has no usable anchor, iOS MUST present it from a safe centered source rect rather than crash.

Session identity

The Web SDK generates a fresh UUID for every presentNativeDialog() call and exposes it synchronously on the returned NativeDialogSession. Web does not accept a caller-provided ID, so two SDK calls cannot accidentally reuse one.

Native MUST:

  1. key its pending dialog registry by the exact sessionId;
  2. reject a duplicate active ID at its input boundary;
  3. copy the ID byte-for-byte into every normal terminal result;
  4. settle the corresponding bridge Promise exactly once; and
  5. remove the pending entry before invoking the reply callback.

The SDK rejects a reply whose sessionId differs from the request with BridgeProtocolError. The transport's private Android request ID is separate: it correlates a single postMessage call, while sessionId identifies the user-visible dialog lifecycle across present and dismiss.

Present result

Every normal terminal path resolves one of these unions and echoes sessionId:

type NativeDialogResult =
  | {
      sessionId: string
      outcome: 'action'
      actionId: string
      values?: Record<string, string>
    }
  | {
      sessionId: string
      outcome: 'dismissed'
      reason: 'cancel' | 'programmatic' | 'navigation'
    }
  | {
      sessionId: string
      outcome: 'notPresented'
      reason: 'busy' | 'unavailable'
    }
  • Tapping any declared action, including an explicit cancel action, returns outcome: 'action' and its stable actionId.
  • values is required for a non-cancel prompt action, contains every declared field ID, and is absent for all other actions.
  • Android Back, an allowed outside tap, or interactive action-sheet dismissal returns dismissed/cancel when no declared action was selected.
  • programmatic is the terminal result of a successful nativeDialog.dismiss.
  • navigation is used when the host tears down the dialog because its owning main-frame document navigated or was destroyed. If that document can no longer receive a reply, Native still cleans up the registry and must not deliver the stale result to a new document.
  • busy means another native dialog already owns the surface. Hosts MUST NOT queue or replace dialogs: a queued dialog can become stale before presentation.
  • unavailable means the host currently has no safe presenter (UIViewController / foreground Activity).

Malformed requests reject with a non-secret error string. User cancellation, busy state, and a missing presenter are normal resolved outcomes, not exceptions.

Dismiss result

interface NativeDialogDismissResult {
  sessionId: string
  dismissed: boolean
}

nativeDialog.dismiss targets one exact session. It resolves dismissed: true only when that session was pending or active and the host initiated its dismissal. Unknown or already-settled IDs resolve dismissed: false. The ID is always echoed. A successful dismiss also resolves the original nativeDialog.present Promise once with dismissed/programmatic.

Web SDK

const session = bridge.presentNativeDialog({
  variant: 'confirm',
  title: 'Delete widget?',
  message: 'This cannot be undone.',
  actions: [
    { id: 'cancel', title: 'Cancel', role: 'cancel' },
    { id: 'delete', title: 'Delete', role: 'destructive' },
  ],
})

console.log(session.sessionId) // available before native answers
const result = await session.result

The SDK parses both outgoing options and the unknown native reply at the bridge boundary. After that parse, application code consumes the discriminated union without repeating defensive checks.

Host scope and ownership

native_dialog.v1 is intended for trusted first-party main-frame embedded surfaces on iOS and Android. Hosts MUST deny sub-frame calls and MUST NOT advertise it on a surface that cannot present native UI. This is a UI authority boundary, not an authentication capability: no tokens, native object handles, controller references, or raw platform errors cross the wire.

The page-visible capability list is feature negotiation, not a security boundary. Native must validate the actual sender frame and trusted origin on every request using host-owned WebView metadata; it must not trust window.__todayWebView values read back from page JavaScript.

Electron app-shell dialogs remain owned by the standardized desktop platform interfaces. They must not be routed through this embedded-WebView capability merely because the shared SDK can see the same channel shape.

On this page