Build panels
The sandbox
What a panel page can and cannot do inside Deska, rule by rule: the network, CORS, storage and files, scripts and styles, forms, links, focus, the clipboard and embeds. Each rule comes with what to do instead.
Why a sandbox
People install panels made by strangers next to their code and their agents. The sandbox is what lets them do that without reading your source: a panel runs in a frame with an opaque origin, in a process of its own, under a strict content security policy, and talks to Deska only through the bridge. Nothing in it can reach the person’s files, other panels, agents, sign-ins or Deska account, or run anything on the computer.
A loaded folder runs under exactly the rules of an installed package, so you meet every limit on this page while you build, not after you publish.
The policy
Every file of a package is served with this Content-Security-Policy, where <package> is the package’s own origin and <hosts> is each host in network as https://host:
default-src 'none';
script-src <package> 'unsafe-inline';
style-src <package> 'unsafe-inline';
img-src <package> data: blob: <hosts>;
font-src <package> data: <hosts>;
media-src <package> blob: <hosts>;
connect-src <package> <hosts>;
form-action 'none';
frame-src 'none';
worker-src 'none';
object-src 'none';
base-uri 'none';
sandbox allow-scripts allow-formsThe frame itself is sandbox="allow-scripts allow-forms", with no allow-same-origin, no popups and no modals. Before any of your scripts, Deska runs a small guard of its own on every page: it removes WebRTC, removes nested frames, objects and prefetch hints as soon as they are added, and holds the focus rules below. The pages a panel shows from other sites are not its frames but Deska’s; see Frames, embeds and players.
Network: declared hosts, https only
fetch, images, fonts, audio and video reach only the package and the hosts in network, over https. Plain http is refused even for a declared host, and so is any host you did not list, including subdomains of one you did.
- Instead: list every host the panel touches, exactly. People see the list before they install, so fewer is better.
- Give every request a timeout and a state for failure, offline and partial results. One host failing should mark only what came from it.
async function getJson(url, ms = 8000) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), ms)
try {
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} finally {
clearTimeout(timer)
}
}HN Front Page and Service Status show timeouts, partial results and polling that follows visibility.
CORS: the API must allow any origin
The page’s origin is opaque, so its requests carry Origin: null. A browser lets the page read an answer only when the server allows any origin with Access-Control-Allow-Origin: *. An API that lists allowed origins, or answers without that header, fails with a network error the page cannot see past.
- Instead: pick APIs that answer
*. Many public ones do (GitHub’s REST API, Open-Meteo, Wikipedia, Hacker News, Statuspage summaries). Test with the preview tool before you design around one. <img>,<audio>and<video>from a declared host need no CORS. Reading their pixels into a canvas does.- Response headers a page may read are limited by CORS too. GitHub exposes its rate limit headers; Repo Pulse uses them.
Storage: deska.storage and deska.files
With an opaque origin, localStorage and sessionStorage throw, indexedDB is unavailable and cookies are never kept.
- Instead:
deska.storage, 256 KB of JSON per package on this computer, shared by every copy of the panel. Keep one versioned document per concern and migrate it on load (Habit Streaks). - Keep images and other files in
deska.files: up to 32 MB a file, 256 MB and 2,000 files per package. Store only their ids indeska.storage. - Cache what the network gave you in storage, so a reload or an offline start draws at once.
Files in and out
- A file picker (
<input type="file">), a paste (clipboardData.filesin the page’s ownpasteevent) and a drop on the page (dataTransfer.files, afterpreventDefault()ondragover) all reach the page. Each one starts with the person. - Downloads are refused,
<a download>included. Instead:deska.files.save(blob, name)from the click. Deska shows the system’s save dialog, the person picks where, and the page never learns the path. - Nothing else of the person’s files is in reach: a panel sees only what it was handed and what it keeps itself.
Scripts: from the package only
Scripts load only from the package; inline scripts run. A script from a CDN is refused, and so is code built from text at run time: eval, new Function and string timers. WebAssembly is not available yet.
- Instead: copy each library into the package as a
.jsor.mjsfile and import it by a relative path. If you use a bundler, ship its output and leave the sources out of the folder you load. - Avoid libraries that compile templates in the browser; use their precompiled build.
- Workers are refused (
worker-src 'none'). Split heavy work into chunks between frames instead.
Styles, fonts, images and media
- Stylesheets load only from the package; inline styles work. A stylesheet from another host, Google Fonts’ CSS included, is refused even if the host is declared. Instead: ship the font as
woff2with an@font-facein your own CSS, or usevar(--deska-font-ui). - Images come from the package,
data:,blob:and declared hosts. Icon fonts from a CDN will not load: draw icons as inline SVG incurrentColor(Weather Now). - Audio and video stream from declared hosts or from
blob:. A package cannot carry media files, so Net Radio streams and Noise Machine generates its sound with Web Audio. - Sound may need a press first:
audio.play()and a newAudioContextcan be refused until the person clicks in the panel. Offer a play button when that happens.
Forms never navigate
form-action 'none' refuses every real submit. Forms, inputs and browser validation still work; only the navigation is gone.
form.addEventListener('submit', (event) => {
event.preventDefault()
search(form.elements.query.value)
})Instead: handle submit in script with preventDefault() and send the data with fetch to a declared host.
Links, popups and dialogs
The frame stays on its own address. Following a link inside it, a navigating form or a change to location is cancelled, and Deska cuts the bridge: the panel says it opened another page until the person presses Reload. window.open, target="_blank", alert, confirm and prompt do nothing.
- Instead: open links with
deska.openUrl(url)from the click (theopen-urlpermission), callingpreventDefault()on the anchor. - Build confirmations and menus in the page. A
<dialog>works while the panel has focus.
Focus and the keyboard
The keyboard stays where the person put it. A panel without focus cannot take it: focus(), window.focus(), opening a dialog or a popover, and click() do nothing until the person uses the panel. On the canvas, the first click on a panel that is not focused only focuses it; the next one reaches your page.
- Instead: move focus inside your page in answer to the person’s own keys and clicks, and ask for attention with
deska.notify. - Keyboard shortcuts work while the panel has focus. Prompt Shelf handles search, arrows, Enter and Escape inside the sandbox.
The clipboard
navigator.clipboardis refused, and nothing can read the clipboard on its own. What the person pastes into the panel reaches the page’spasteevent, as on any page.- Without
clipboard-write, the page’s ownexecCommand('copy')and('cut')do nothing, so a button cannot replace what the person copied. Selecting text and copying it with the keyboard works as on any page. - Instead: ask for
clipboard-writeand calldeska.clipboard.write(text)from the click, up to 64 KB, ordeska.clipboard.writeImage(blob)for a PNG or JPEG up to 32 MB.
Frames, embeds and players
A panel never frames anything itself. No <iframe>, <object> or <embed> survives: the policy keeps frame-src 'none' and the guard removes any that are added.
Instead: to show another site’s page, like a YouTube player, a map or a public board, list its host under embeds in panel.json and ask Deska with deska.embed. Deska draws a frame of its own over the panel, where the page asked, and keeps it safe:
- The embedded page runs signed out, with throwaway cookies and storage of its own on every load. It never sees the person’s accounts, Deska’s session or another panel’s data.
- It may play on its own and use picture in picture, and nothing else: no popups, downloads, camera, microphone, clipboard or full screen. Links in it that open a new window do nothing.
- The panel cannot read it, script it, navigate it or draw over it. It only says where the frame sits, and Deska keeps the frame inside the panel.
- Only https pages on the hosts in
embeds, on the default port. Deska’s own sites and sign-in and payment pages are never allowed. - The question before installing names every host the panel shows pages from, and an update that adds one asks again.
- A host it embeds can receive whatever the panel puts in the address, so treat each one like a host in
network. - Publishing a panel with
embedsneeds an account Deska approved to publish. - A page on localhost (your dev server) cannot embed; a loaded folder can.
Without embeds, play media files from a declared host with <audio> or <video> (Window View), draw what an API returns, and open the full site with deska.openUrl. To browse a whole website on the desk, Deska’s own browser panel is the place, not a community panel.
Devices and system features
| Feature | In a panel | Instead |
|---|---|---|
| Microphone, camera, screen | Refused | Not available to panels. |
| Location | Refused | Ask for a city and geocode it (Weather Now). |
| Browser notifications | Refused | deska.notify |
| WebRTC | Removed | Not available to panels. |
| Downloads | Refused | deska.files.save |
| Service workers, workers | Refused | Do the work in the page, in chunks. |
No secrets
A panel has nowhere safe to keep a token. deska.storage is plain JSON on the computer that every copy of the package reads, anything shipped in the package is public, and anything typed into the page lives in its memory.
Running, hidden and reloaded
- A panel runs while one of its frames is mounted. Scrolled off screen, behind a far view card or on a hidden worktree, it keeps running and is told
visible: false. Stop timers, animations and polling there. - It can reload at any time (docked, split, detached, updated, restarted). Rebuild from
deska.ready()and storage. - It runs in a process of its own. A loop that never yields freezes the panel, not Deska.
- Pages on localhost run without this policy and guard; see Testing and debugging before you trust one.