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()andXMLHttpRequestcannot readfile: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.