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):
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):
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)
})<!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#
| Field | Type | Does |
|---|---|---|
id | string | Required. Unique per extension |
title | Bindable<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 }) |
rpc | string[] | Ids of the RPC contracts the page may call. Default: none |
A screen webview opens only through navigation, like any screen:
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.
| Direction | Message |
|---|---|
| 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:
type | Means |
|---|---|
A declared error name (too-big) | The method returned or threw call.error(name, …). data holds its data |
rpc.not-allowed | The contract is not in the item's rpc list. The call never reached the extension |
rpc.unavailable | No extension implements the contract, or the app has no RPC |
rpc.invalid-input, rpc.invalid-output | The input or the output failed the contract's schema |
rpc.internal | The 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:
| Directive | Allows |
|---|---|
default-src | Nothing |
script-src, style-src | Files from the page's own folder, and inline code |
img-src | The page's folder, data: and blob: URLs |
font-src, media-src | The page's folder (fonts also data: in the web app, media also blob:) |
connect-src | Nothing: no fetch, no WebSocket |
form-action | Nothing |
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 app | Desktop app | Web app | |
|---|---|---|---|
placement: 'panel' | Not shown | A side panel tab, with a browser icon | A 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 app | A large dialog with the page | A large dialog with the page |
file source | Not shown | Served by the app from the extension's folder | Served by the server, at a URL that needs a token and expires after 12 hours |
url source | Opened in the browser | http(s) only | As 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 getsrpc.not-allowed, even for the extension's own contract. fetchfails 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 afilesource.localStorageand 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 packchecks that eachfilesource exists inside the folder. Run it before you publish (publishing).