- Node.js (v20+)
- npm
# Install dependencies (first time only)
npm install
# Dev build (with source maps)
npm run build:chrome
# Production build (minified, no source maps)
npm run build:chrome:prod
# Build both Safari and Chrome at once
npm run build:allThe Chrome build outputs to distros/chrome/. This folder contains
everything Chrome needs to load the extension.
- Open Chrome and navigate to
chrome://extensions/. - Enable Developer mode (toggle in the top-right corner).
- Click Load unpacked.
- Select the
distros/chrome/folder inside the project root. - The NostrKey extension icon should appear in the toolbar.
After rebuilding (npm run build:chrome), go back to
chrome://extensions/ and click the reload arrow on the NostrKey card,
or press the keyboard shortcut shown on that page.
| Aspect | Safari | Chrome |
|---|---|---|
| Background | Background page (background.html) |
Service worker (background-sw.build.js) |
| API namespace | browser.* (native) |
chrome.* (normalised by browser-polyfill.js) |
| Manifest extras | browser_specific_settings.safari |
host_permissions, open_in_tab on options |
| Output location | distros/safari/ |
distros/chrome/ |
- "Service worker registration failed" -- Make sure you ran
npm run build:chromeso thatbackground-sw.build.jsexists indistros/chrome/. - CSP errors in the console -- Chrome MV3 requires
script-src 'self'for extension pages. The Chrome manifest already includes this. - Icons missing -- Verify the
images/folder was copied intodistros/chrome/. The build script handles this automatically. - Stale code after editing -- Remember to rebuild and reload the
extension in
chrome://extensions/.
If the background service worker logs correct processing but the caller
(sidepanel, options page, etc.) receives undefined, the cause is almost
certainly a Promise-return vs sendResponse mismatch.
Root cause: Our browser-polyfill.js wraps chrome.runtime.sendMessage
with a callback via promisify(). Chrome only delivers sendResponse()
calls to that callback — values resolved from a returned Promise are silently
lost.
Broken pattern (do NOT use):
case 'myAction':
return (async () => {
const result = await doSomething();
return result; // ❌ caller gets undefined
})();Correct pattern:
case 'myAction':
reply(sendResponse, async () => {
const result = await doSomething();
return result; // ✅ reply() calls sendResponse(result)
});
return true; // keep the message channel openThe reply(sendResponse, asyncFn) helper in background.js wraps async
work and calls sendResponse with the resolved value (or undefined on
error). Every handler in the onMessage switch must either:
- Use
reply(sendResponse, async () => { ... }); return true;for async work - Use
sendResponse(value); return true;for sync responses - Use
return false;in the default case (message not handled)
Symptom checklist:
- Background console shows the handler ran and produced correct output
- Caller receives
undefinedinstead of the expected value - No errors in either console — the value is silently dropped
This applies to ALL message handlers, not just lock/encryption ones. See
src/background.js for the full implementation.
After reloading the extension from chrome://extensions/, the service
worker restarts and in-memory state (encryptionEnabled, locked) resets.
The isEncrypted flag in storage should restore this, but timing issues can
cause it to fail.
NostrKey uses three-tier vault detection to handle this:
- Flag check —
isEncryptedinbrowser.storage.local(fast, best case) - Deep scan —
hasEncryptedDatamessage scanspasswordHashand profileprivKeyfields for encrypted blobs (automatic fallback in sidepanel init) - Manual button — "Check for Existing Vault" in the Secure Your Vault card (user-triggered fallback if both auto paths fail)
If the deep scan finds encrypted data, it self-heals the isEncrypted flag
so subsequent loads work instantly.
NostrKey uses storage.sync to mirror data across devices:
- Chrome: syncs via the user's Google account
- Safari 16+: syncs via iCloud
The "storage" permission covers both storage.local and storage.sync.
Architecture:
storage.localis always the source of truthSyncManager(src/utilities/sync-manager.js) handles push/pull/merge- Writes to
storage.localinbackground.jsauto-trigger a 2-second debounced push - On startup,
initSync()pulls from sync and merges into local storage.onChangedlistener picks up remote changes in real-time
Limits: 100 KB total, 8 KB per item, 512 items max. Values exceeding
8 KB are transparently chunked (_chunk:key:0, _chunk:key:1, etc.).
Debugging:
- Open DevTools → Application → Storage → Extension Storage (sync)
- Console logs prefixed with
[SyncManager]show push/pull/merge activity - If sync seems stuck, check that
platformSyncEnabledistruein local storage - Budget exhaustion warnings appear as
console.warnmessages
DB version: v6 adds updatedAt timestamps to profiles for conflict resolution.