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 console output anywhere. Inside NepTunes, console.log and 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, not log. log is a zsh builtin, and zsh is the default shell on macOS. The bare command gets you zsh:log:1: too many arguments and 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 defaultSize looks like it does nothing. The window size a user has is saved against manifest.version, so an existing install keeps its old size until you bump the version. Bump manifest.version whenever anything in the bundle changes.
  • Watch --predicate 'category == "DisplayChange"' while you resize or move a widget: RESTORE_SIZE, RESTORE_DONE and WINDOW_DID_MOVE report 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. A file: page has an opaque origin, so use <script src>, <img src> and <link rel="stylesheet"> instead.
  • Handle the no-track state. NepTunes.track (and state.track) is null when nothing is playing. Guard every access to track.title, track.artist, etc.
  • All of NepTunes.capabilities reading false means you have no state yet, not that the player supports nothing — it falls back to an all-false object until the first state arrives. Don’t build your UI on it before statechange fires.
  • Always .catch() the promise APIs. NepTunes.lastFm.* rejects with Last.fm request timed out and NepTunes.symbol() with Symbol 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);
}

← PermissionsPublishing guidelines →