Skip to content

Extension webviews

Show a sandboxed web page from an extension in a panel or a screen, and let it call the extension through RPC.

A webview is a web page an extension shows inside the app: in a side panel tab, or in a screen. The page is your own HTML, CSS and scripts, in a sandboxed frame. It cannot reach the network, the app or the disk. It talks to the extension through one bridge: calls to the RPC contracts the item allows.

Use a webview for a full custom UI that the data items and the kit components cannot draw: a canvas, an editor, a page you already have. It costs more than a component: it does not follow the app's theme, and the terminal app cannot show it.

A minimal example#

The kitchen sink adds a page from its folder in a side panel tab (examples/extension-kitchen-sink/ui-items.ts):

TypeScript
import { Rpc, Webview } from 'zidane'
import { z } from 'zod'

const KitchenRpc = Rpc.define({
  id: 'kitchen-sink',
  methods: {
    count: { output: z.number() },
    bump: { input: z.object({ by: z.number().int().min(1) }), output: z.object({ count: z.number() }), errors: { 'too-big': z.object({ limit: z.number() }) } },
  },
})

ctx.add(Webview, { id: 'page', title: 'Kitchen page', src: { file: 'webview/index.html' }, placement: 'panel', rpc: [KitchenRpc.id] })

The page posts calls to its parent window, and gets the results back (webview/page.js):

webview/page.js
let nextId = 1
const pending = new Map()

function call(method, input) {
  const id = nextId++
  window.parent.postMessage({ type: 'zidane:rpc', id, contract: 'kitchen-sink', method, input }, '*')
  return new Promise((resolve, reject) => pending.set(id, { resolve, reject }))
}

window.addEventListener('message', (event) => {
  const data = event.data
  if (!data || data.type !== 'zidane:rpc:result' || !pending.has(data.id))
    return
  const { resolve, reject } = pending.get(data.id)
  pending.delete(data.id)
  if (data.ok)
    resolve(data.output)
  else
    reject(data.error)
})

call('count').then((count) => {
  document.getElementById('count').textContent = String(count)
})
webview/index.html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="page.css">
  </head>
  <body>
    <p>Counter: <strong id="count">…</strong></p>
    <script src="page.js"></script>
  </body>
</html>

The extension implements the contract with ctx.rpc.register as usual (RPC). The page is one more caller.

The item#

FieldTypeDoes
idstringRequired. Unique per extension
titleBindable<string>Required. The tab label, or the screen title
src{ file: string } | { url: string }Required. A file in a subfolder of the extension's folder, relative, without ... Or an http(s):// URL
placement'panel' | 'screen'Required. A side panel tab, or a screen opened with ctx.ui.navigate({ screen: id })
rpcstring[]Ids of the RPC contracts the page may call. Default: none

A screen webview opens only through navigation, like any screen:

TypeScript
import { Webview } from 'zidane'

ctx.add(Webview, { id: 'editor', title: 'Recipe editor', src: { file: 'webview/editor.html' }, placement: 'screen', rpc: ['kitchen-sink'] })
ctx.commands.register({ id: 'edit-recipes', title: 'Edit recipes', run: () => void ctx.ui.navigate({ screen: 'editor' }) })

The bridge#

The page sends a message to window.parent, and the app answers with a message to the page.

DirectionMessage
Page to app{ type: 'zidane:rpc', id, contract, method, input? }. id is a string or a number of your choice, echoed in the answer
App to page, success{ type: 'zidane:rpc:result', id, ok: true, output }
App to page, failure{ type: 'zidane:rpc:result', id, ok: false, error: { type, message, data? } }

The app checks that a message comes from the page's own frame, and ignores any other. When the app runs the extension itself, a call runs as the extension's UI: the implementation sees caller.kind === 'ui'. Through the daemon or a server, it runs as a client of the window's connection: a local daemon does not limit it, and on a server a method above the window's token scope answers rpc.forbidden. Inputs and outputs are validated as for any caller.

error.type is one of:

typeMeans
A declared error name (too-big)The method returned or threw call.error(name, …). data holds its data
rpc.not-allowedThe contract is not in the item's rpc list. The call never reached the extension
rpc.unavailableNo extension implements the contract, or the app has no RPC
rpc.invalid-input, rpc.invalid-outputThe input or the output failed the contract's schema
rpc.internalThe method threw something else

Calls that are still running end when the page goes away.

The sandbox#

The frame runs with sandbox="allow-scripts allow-forms" and without allow-same-origin: the page has an opaque origin, no cookies, no storage shared with the app. A page from the extension's folder is served with a content security policy:

DirectiveAllows
default-srcNothing
script-src, style-srcFiles from the page's own folder, and inline code
img-srcThe page's folder, data: and blob: URLs
font-src, media-srcThe page's folder (fonts also data: in the web app, media also blob:)
connect-srcNothing: no fetch, no WebSocket
form-actionNothing

Only files in the folder of the src file are served. Put the page and its assets in one folder, as the kitchen sink's webview/. For data, call the extension: it can fetch, read files and use its integrations.

A url source loads that page in the same sandboxed frame. The page's own server sets its policy, and the bridge works the same way.

In each app#

Terminal appDesktop appWeb app
placement: 'panel'Not shownA side panel tab, with a browser iconA side panel tab
placement: 'screen'A box with the title and the URL. Enter opens a url source in the system browser. A file source says to open it in the desktop appA large dialog with the pageA large dialog with the page
file sourceNot shownServed by the app from the extension's folderServed by the server, at a URL that needs a token and expires after 12 hours
url sourceOpened in the browserhttp(s) onlyAs given

After the web app reconnects to a restarted server, it asks for new page URLs, so open webviews load again.

Pitfalls#

  • Allow each contract in rpc. A page that calls an unlisted contract gets rpc.not-allowed, even for the extension's own contract.
  • fetch fails in a page from the extension's folder. Put network calls in the extension, behind an RPC method.
  • file:// URLs do not show in the desktop app. Use a file source.
  • localStorage and cookies do not work in the sandbox. Keep state in a store and read it through RPC.
  • The page does not follow the app's theme. If it must match, prefer a component.
  • zidane extension pack checks that each file source exists inside the folder. Run it before you publish (publishing).