Debugging
Start with the constraint, because it shapes everything else on this page:
A running widget cannot be inspected, and it has no console. Widget web views are not exposed to Safari’s Web Inspector, and NepTunes does not forward your widget’s
consoleoutput anywhere. Inside NepTunes,console.logand uncaught script errors both go nowhere.
So don’t debug in place. Debug your widget as what it is — a web page — in a browser, where you have real developer tools, and use NepTunes for integration only. Everything below is built around that split: what you do in the browser, and the two things NepTunes will tell you.
Develop it in a browser
A widget is plain HTML, CSS and JavaScript loaded from disk. Open index.html in Safari or
Chrome and you get the full inspector — DOM, styles, breakpoints, console, the lot.
The only thing missing is window.NepTunes, which the host normally injects. Stub it. Drop this
in a dev-stub.js, load it before your own script, and leave it out of the shipped bundle:
<!-- remove this line before you package -->
<script src="dev-stub.js"></script>
<script src="widget.js"></script>
// dev-stub.js — a stand-in for the API NepTunes injects. Development only.
(function () {
if (window.NepTunes) return; // real host wins
const listeners = {};
const state = {
track: { title: 'Bloom', artist: 'Beach House', album: 'Bloom' },
playerState: 2, // 1 stopped, 2 playing, 3 paused
volume: 65,
isMuted: false,
playerType: 'music',
capabilities: {
canLove: true, canDislike: true, canRate: true,
canAddToLibrary: true, hasThreeStateRepeat: false
}
};
const log = (...a) => console.log('[stub]', ...a);
window.NepTunes = {
state,
settings: {},
on: (e, cb) => (listeners[e] ||= []).push(cb),
off: (e, cb) => (listeners[e] = (listeners[e] || []).filter(f => f !== cb)),
_emit: (e, d) => (listeners[e] || []).forEach(cb => cb(d)),
getState: () => state,
getSettings: () => ({}),
get track() { return this.state?.track || null; },
get isPlaying() { return this.state?.playerState === 2; },
get isPaused() { return this.state?.playerState === 3; },
get isStopped() { return this.state?.playerState === 1; },
get volume() { return this.state?.volume || 0; },
get isMuted() { return this.state?.isMuted || false; },
get playerType() { return this.state?.playerType || null; },
get capabilities() { return this.state?.capabilities || {}; },
performAction: (a) => log('performAction', a),
setVolume: (v) => log('setVolume', v),
setRating: (r) => log('setRating', r),
setSize: (w, h) => log('setSize', w, h),
getArtworkDataURL: () => null,
symbol: () => Promise.resolve(''),
lastFm: new Proxy({}, { get: (_, m) => () => Promise.resolve({ stub: m }) })
};
// Drive your own state changes from the browser console:
// NepTunes.state.playerState = 3; NepTunes._emit('statechange', NepTunes.state)
window.NepTunes._emit('statechange', state);
})();
Now you can exercise the states that are awkward to reach in a real player — no track, a very
long title, a missing album, a paused player, a capability switched off — by editing state in
the browser console and re-emitting. Match the real event names: statechange, themechange
and settingschange are the three the host emits.
Two habits worth keeping while you’re there:
- Test both appearances. Toggle the browser’s light/dark simulation. In NepTunes the host
re-broadcasts system appearance changes as
themechange. - Test light and dark artwork. Album art varies wildly — make sure your text stays legible over both. A subtle scrim or text shadow helps.
Get output out of a widget with no console
Once it’s installed and you need to see something from inside the real host, render it. The page is the only output channel you have, so print to it:
const debugEl = Object.assign(document.createElement('pre'), {
style: 'position:fixed;inset:auto 0 0 0;max-height:40%;overflow:auto;margin:0;' +
'font:10px/1.3 ui-monospace,monospace;background:rgba(0,0,0,.75);color:#0f0;' +
'padding:4px;z-index:99999;white-space:pre-wrap'
});
document.body.appendChild(debugEl);
const show = (...a) => { debugEl.textContent =
a.map(x => typeof x === 'string' ? x : JSON.stringify(x, null, 1)).join(' ') +
'\n' + debugEl.textContent; };
window.onerror = (m, src, line) => show('ERROR', m + ' @' + line);
window.addEventListener('unhandledrejection', e => show('REJECTED', String(e.reason)));
NepTunes.on('statechange', s => show('state', s));
The two handlers matter more than the state dump. A single parse error takes out the whole
widget rather than just the broken function, and a rejected promise is silent — with no console,
window.onerror and unhandledrejection are the only way either becomes visible. Strip the
block before you publish.
Read the host’s log
Everything NepTunes decides about your widget — refused permissions, blocked navigations, bundles that won’t load, window placement — goes to the unified log. Run this, then reproduce:
/usr/bin/log stream --process "NepTunes Widget" --level debug
Two details in that command are load-bearing:
/usr/bin/log, notlog.logis a zsh builtin, and zsh is the default shell on macOS. The bare command gets youzsh:log:1: too many argumentsand no output at all, which reads exactly like a host that logs nothing.- Filter by process, not by subsystem. The helper writes under more than one subsystem and some of its lines carry none, so a subsystem filter silently drops them. The process catches everything.
--level debug is genuinely needed: many of the useful lines are logged at debug and info,
which the log tool hides unless you ask.
Narrow by category once it’s streaming — --predicate 'category == "JSBridge"':
| Category | What lands here |
|---|---|
JSBridge |
permissions, actions, Last.fm calls — everything your JS triggers |
WindowController |
bundle loading, navigation blocks, reloads |
DisplayChange |
window placement, size restoration, monitors coming and going |
AppDelegate |
helper startup, which widgets were activated, quarantine |
Strings worth searching for, all tagged with the widget id:
lacks permission for action:— your action was refused because the manifest didn’t ask for that permission. This is the answer to “my button does nothing”.QUARANTINE:— your widget crashed the helper while loading, and is being skipped.Ignoring unknown WebKit KVC key— rare, and not your fault. Worth reporting.
Check the bundle before you install it
Most “it won’t load” cases are decidable without NepTunes at all:
node widget-tools.mjs validate MyWidget.nepget
That checks manifest.json against the same schema the host applies before it will build a web
view. node widget-tools.mjs embed-verify MyWidget.nepget checks the signature. See
Updates & signing.
Reload as you edit
The host watches your installed bundle’s folder and reloads the web view when it changes, so most edits show up the moment you save.
Two things worth knowing, because they decide whether that works for you:
- It watches the folder, not each file. Adding or renaming a file always triggers it, and so does an editor that saves atomically (writes a temp file, then renames it — most do). An editor that rewrites a file in place does not trigger it.
- If your edit doesn’t take, force a full reload: turn the widget off and back on in
Settings → Widgets. That rebuilds the window and the web view from scratch, which also
re-reads
manifest.json.
Edit the copy that’s actually installed, in
~/Library/Group Containers/group.pl.micropixels.NepTunes/Widgets/<your.widget.id>/ — editing
the .nepget you built the bundle from changes nothing until you reinstall it.
When nothing appears
A widget that doesn’t render, or vanishes, is usually one of these — in rough order of how often it’s the answer.
Nothing is playing. Widgets are hidden whenever there’s no running player or no current track. This is a NepTunes-wide rule, not something your widget did: start playing something and every widget comes back.
A JavaScript error killed the page. One parse error takes out the whole widget, not just
the broken function. With no console this is invisible — which is why you develop in a browser
and install a window.onerror handler when you can’t.
The bundle won’t load at all. An invalid manifest.json, a bad defaultSize, a signature
that doesn’t verify — these never reach a web view. Settings → Widgets lists such a bundle
with a warning triangle and a plain-language reason. The precise reason, with the offending
value — defaultSize 99999×200 is outside 1…16384 — goes only to the log, so read both.
The widget crashed the helper while loading. If building your widget takes the whole widget
process down, NepTunes remembers which one it was and skips it for the following launch — so it
stays missing one more time even after you’ve fixed it, which is easy to read as “the fix didn’t
work”. Look for QUARANTINE: in the log. Turn it off and back on in Settings → Widgets to
load it right away.
Debugging the UI
Widgets are transparent, borderless windows the user drags around, which creates failure modes that don’t exist in an ordinary web page — and that a browser can’t reproduce.
Your control doesn’t respond — the window moves instead
Dragging is handled by the host on the capture phase, before your own handlers run. It steps aside only for elements it recognises as interactive:
- Tags:
<button>,<input>,<select>,<textarea>,<a> - Classes:
control-btn,icon-btn,rating-btn,header-btn,slider,resize-handle
The check walks up from the event target to <body>, so a child of any of those is fine. A
custom control built from a <div> with your own class name is not recognised — dragging it
moves the window instead of operating your control. Give it a semantic tag, or one of those
exact class names. This is the single most common “my widget ignores clicks” cause.
A click fires when you only meant to drag
Move more than 4px and the host swallows the synthetic click WebKit fires at the end of a drag, so a whole-body click-to-toggle doesn’t fire every time the user repositions the window. Move less and it counts as a tap and passes through. If a stationary click isn’t registering, check you aren’t nudging the mouse between press and release.
Nothing inside an <iframe> is draggable
Mouse events inside an iframe never reach the host, so that region cannot move the window.
Size and position
- Changing
defaultSizelooks like it does nothing. The window size a user has is saved againstmanifest.version, so an existing install keeps its old size until you bump the version. Bumpmanifest.versionwhenever anything in the bundle changes. - Watch
--predicate 'category == "DisplayChange"'while you resize or move a widget:RESTORE_SIZE,RESTORE_DONEandWINDOW_DID_MOVEreport the frame the host actually applied, and whether it decided to save it.
Common gotchas
- Remote assets are blocked, silently. No CDN scripts, no Google Fonts, no remote images — a widget that looks unstyled or icon-less is almost always this. Bundle everything. See Security & the sandbox.
fetch()can’t read your own files either. Afile:page has an opaque origin, so use<script src>,<img src>and<link rel="stylesheet">instead.- Handle the no-track state.
NepTunes.track(andstate.track) isnullwhen nothing is playing. Guard every access totrack.title,track.artist, etc. - All of
NepTunes.capabilitiesreadingfalsemeans you have no state yet, not that the player supports nothing — it falls back to an all-falseobject until the first state arrives. Don’t build your UI on it beforestatechangefires. - Always
.catch()the promise APIs.NepTunes.lastFm.*rejects withLast.fm request timed outandNepTunes.symbol()withSymbol request timed out. A widget that appears frozen mid-render is usually an unhandled rejection you cannot see. - Respect capabilities. Hide controls the current player can’t support (see Permissions).
Shadows and transparency
Widgets render on a transparent background. To cast a drop shadow, leave room for it with
body padding and keep backgrounds transparent:
html, body {
height: 100%;
width: 100%;
overflow: visible;
background: transparent;
}
body {
padding: 20px; /* room for the shadow */
}
.widget {
height: 100%;
width: 100%;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}