Security & the sandbox

A widget is third-party HTML, CSS and JavaScript that runs on someone’s desktop. NepTunes treats it as untrusted: it renders in a WKWebView inside a separate helper process, with four hard limits around it.

None of these are guidelines you can bend. They’re enforced, and a widget that ignores them doesn’t degrade — it silently does nothing. Read this before you write the first line, and you’ll save yourself an afternoon.

1. No network access

All remote traffic is blocked. Not rate-limited, not discouraged — blocked, for every scheme a widget could reach the internet with:

Blocked Applies to
http: https: fetch(), XMLHttpRequest, WebSocket, <img src>, <script src>, <link href>, CSS url(), <video>, fonts
ws: wss: WebSocket
ftp: ftps: any subresource

So no CDN links, no remote fonts, no analytics, no “just this one API call”. Bundle every asset you use. A Google Fonts <link> is the single most common way a widget arrives looking broken.

If you need data from the internet, you don’t fetch it — you ask NepTunes for it. The lastFm permission and the NepTunes.lastFm methods route through the app, which already holds the credentials and the network access.

2. Your widget cannot navigate away from itself

The only navigations allowed are file: URLs inside your own bundle, plus about: (which covers about:blank and <iframe srcdoc>). Everything else is cancelled, including http(s):, data: documents and custom app schemes.

In practice:

window.location = 'https://example.com';   // cancelled
window.open('https://example.com');        // cancelled
<a href="https://example.com">link</a>     <!-- cancelled -->
<a href="#section">anchor</a>              <!-- fine -->
<iframe srcdoc="<p>hi</p>"></iframe>       <!-- fine -->
<iframe src="./panel.html"></iframe>       <!-- fine, in-bundle -->

There is deliberately no way to open a URL in the user’s browser — no bridge action for it. If your widget’s design depends on sending someone to a web page, it needs a different design.

3. You can only read files inside your bundle

The web view is granted read access to your bundle directory and nothing else. ../, absolute paths and other widgets’ folders are all unreachable.

One consequence surprises people, so it’s worth stating plainly:

fetch() and XMLHttpRequest cannot read file: URLs at all — including your own.

A page loaded from file: has an opaque origin, so the browser refuses these before the sandbox is even consulted. This is not a NepTunes rule; it’s how WebKit treats local pages.

To load your own bundled files, use ordinary markup instead:

<script src="./data.js"></script>          <!-- works -->
<img src="./cover.png">                    <!-- works -->
<link rel="stylesheet" href="./ui.css">    <!-- works -->
fetch('./data.json')   // fails, even though the file is right there

If you need structured data, ship it as a .js file that assigns to a global, or inline it in a <script type="application/json"> tag and read it from the DOM.

4. Storage does not persist

Each widget gets its own non-persistent data store. localStorage, sessionStorage, IndexedDB and cookies all work while the widget is running, and all disappear when the helper restarts. They’re also not shared with any other widget.

Use them for scratch state, never for anything the user would expect to survive a reboot. To persist real settings, declare a settings schema — those are stored by NepTunes, shown in the app’s UI, and pushed back to you.

What you can rely on

  • Everything inside your own bundle.
  • data: URLs — including the SF Symbol bridge.
  • The JavaScript API, within the permissions you declared.
  • CSS, animation, canvas, SVG, Web Audio — the whole local web platform.

Permissions are enforced at runtime

Declaring a permission is not paperwork. Calls whose permission you haven’t declared are refused by the host and never reach the player, and each refusal is written to the log:

Widget <your.widget.id> lacks permission for action: increaseVolume

If a control in your widget does nothing, that line tells you why. See Debugging for how to read the log.

Signing is part of the build

A .nepget carries a bundle.sig covering every file in it. Edit so much as one byte after signing and the bundle stops loading — not just at install time, but on every launch, because the signature is checked each time the widget is loaded.

So the order is always: finish your changes, then sign, then package. See Updates & signing.

← Build with an AI assistant