Compare commits
2 Commits
5b7b06ff5d
...
72b9dcddc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b9dcddc1 | ||
|
|
387796faa3 |
148
.eslintrc.json
148
.eslintrc.json
@@ -1,148 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"sourceType": "module",
|
||||
"allowImportExportEverywhere": false
|
||||
},
|
||||
|
||||
"env": {
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
|
||||
"plugins": [
|
||||
"react",
|
||||
"babel"
|
||||
],
|
||||
|
||||
"globals": {
|
||||
"document": false,
|
||||
"navigator": false,
|
||||
"window": false,
|
||||
"location": false,
|
||||
"URL": false,
|
||||
"URLSearchParams": false,
|
||||
"fetch": false,
|
||||
"EventSource": false,
|
||||
"localStorage": false,
|
||||
"sessionStorage": false
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"react/jsx-uses-vars": 2,
|
||||
"react/jsx-no-undef": 2,
|
||||
"react/jsx-uses-react": 2,
|
||||
"accessor-pairs": 2,
|
||||
"arrow-spacing": [2, { "before": true, "after": true }],
|
||||
"block-spacing": [2, "always"],
|
||||
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
|
||||
"comma-dangle": 0,
|
||||
"comma-spacing": [2, { "before": false, "after": true }],
|
||||
"comma-style": [2, "last"],
|
||||
"constructor-super": 2,
|
||||
"curly": [0, "multi-line"],
|
||||
"dot-location": [2, "property"],
|
||||
"eol-last": 2,
|
||||
"eqeqeq": [2, "allow-null"],
|
||||
"generator-star-spacing": [2, { "before": true, "after": true }],
|
||||
"handle-callback-err": [2, "^(err|error)$" ],
|
||||
"indent": 0,
|
||||
"jsx-quotes": [2, "prefer-double"],
|
||||
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
|
||||
"keyword-spacing": [2, { "before": true, "after": true }],
|
||||
"new-cap": 0,
|
||||
"new-parens": 0,
|
||||
"no-array-constructor": 2,
|
||||
"no-caller": 2,
|
||||
"no-class-assign": 2,
|
||||
"no-cond-assign": 2,
|
||||
"no-const-assign": 2,
|
||||
"no-control-regex": 0,
|
||||
"no-debugger": 0,
|
||||
"no-delete-var": 2,
|
||||
"no-dupe-args": 2,
|
||||
"no-dupe-class-members": 2,
|
||||
"no-dupe-keys": 2,
|
||||
"no-duplicate-case": 2,
|
||||
"no-empty-character-class": 2,
|
||||
"no-empty-pattern": 2,
|
||||
"no-eval": 0,
|
||||
"no-ex-assign": 2,
|
||||
"no-extend-native": 2,
|
||||
"no-extra-bind": 2,
|
||||
"no-extra-boolean-cast": 2,
|
||||
"no-extra-parens": [2, "functions"],
|
||||
"no-fallthrough": 2,
|
||||
"no-floating-decimal": 2,
|
||||
"no-func-assign": 2,
|
||||
"no-implied-eval": 2,
|
||||
"no-inner-declarations": [0, "functions"],
|
||||
"no-invalid-regexp": 2,
|
||||
"no-irregular-whitespace": 2,
|
||||
"no-iterator": 2,
|
||||
"no-label-var": 2,
|
||||
"no-labels": [2, { "allowLoop": false, "allowSwitch": false }],
|
||||
"no-lone-blocks": 2,
|
||||
"no-mixed-spaces-and-tabs": 2,
|
||||
"no-multi-spaces": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-multiple-empty-lines": [2, { "max": 2 }],
|
||||
"no-native-reassign": 2,
|
||||
"no-negated-in-lhs": 2,
|
||||
"no-new": 0,
|
||||
"no-new-func": 2,
|
||||
"no-new-object": 2,
|
||||
"no-new-require": 2,
|
||||
"no-new-symbol": 2,
|
||||
"no-new-wrappers": 2,
|
||||
"no-obj-calls": 2,
|
||||
"no-octal": 2,
|
||||
"no-octal-escape": 2,
|
||||
"no-path-concat": 0,
|
||||
"no-proto": 2,
|
||||
"no-redeclare": 2,
|
||||
"no-regex-spaces": 2,
|
||||
"no-return-assign": 0,
|
||||
"no-self-assign": 2,
|
||||
"no-self-compare": 2,
|
||||
"no-sequences": 2,
|
||||
"no-shadow-restricted-names": 2,
|
||||
"no-spaced-func": 2,
|
||||
"no-sparse-arrays": 2,
|
||||
"no-this-before-super": 2,
|
||||
"no-throw-literal": 2,
|
||||
"no-trailing-spaces": 2,
|
||||
"no-undef": 2,
|
||||
"no-undef-init": 2,
|
||||
"no-unexpected-multiline": 2,
|
||||
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
|
||||
"no-unreachable": 2,
|
||||
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_"}],
|
||||
"no-useless-call": 2,
|
||||
"no-useless-constructor": 2,
|
||||
"no-with": 2,
|
||||
"one-var": [0, { "initialized": "never" }],
|
||||
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
|
||||
"padded-blocks": [2, "never"],
|
||||
"quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
|
||||
"semi": [2, "never"],
|
||||
"semi-spacing": [2, { "before": false, "after": true }],
|
||||
"space-before-blocks": [2, "always"],
|
||||
"space-before-function-paren": 0,
|
||||
"space-in-parens": [2, "never"],
|
||||
"space-infix-ops": 2,
|
||||
"space-unary-ops": [2, { "words": true, "nonwords": false }],
|
||||
"spaced-comment": 0,
|
||||
"template-curly-spacing": [2, "never"],
|
||||
"use-isnan": 2,
|
||||
"valid-typeof": 2,
|
||||
"wrap-iife": [2, "any"],
|
||||
"yield-star-spacing": [2, "both"],
|
||||
"yoda": 0,
|
||||
"no-unsafe-optional-chaning": 0
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
arrowParens: avoid
|
||||
bracketSpacing: false
|
||||
jsxBracketSameLine: false
|
||||
printWidth: 80
|
||||
proseWrap: preserve
|
||||
semi: false
|
||||
singleQuote: true
|
||||
trailingComma: none
|
||||
useTabs: false
|
||||
40
AGENTS.md
Normal file
40
AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
bun run dev # Development: runs build.js + tailwindcss --watch
|
||||
bun run build # Production build → extension/output
|
||||
bun run package:chrome # Creates extension/releases/nostrconnect_chrome.zip
|
||||
bun run package:firefox # Creates extension/releases/nostrconnect_firefox.xpi
|
||||
bun run lint # Run Biome linter
|
||||
bun run format # Format with Biome (auto-fix)
|
||||
bun run test # Run tests with Vitest
|
||||
bun run test:watch # Run tests in watch mode
|
||||
```
|
||||
|
||||
## Key Facts
|
||||
|
||||
- **Package manager**: bun (preferred) or pnpm. Uses `@jsr` registry for `@nostr/tools`
|
||||
- **Build output**: `extension/output/` (not `dist/`)
|
||||
- **Ignore `extension/output/`**: It contains generated JS/HTML/CSS and is gitignored
|
||||
- **Tailwind source**: `extension/style.css` → `extension/output/style.css`
|
||||
|
||||
## Architecture
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------------- | --------------------------------------------------------- |
|
||||
| `background.js` | Core logic: state, permissions, crypto (signEvent, nip04) |
|
||||
| `nostr-provider.js` | Injected into web pages, provides `window.nostr` |
|
||||
| `content-script.js` | Bridges provider ↔ background via postMessage |
|
||||
| `popup.jsx`, `prompt.jsx`, `options.jsx` | React UI components |
|
||||
| `extension/chrome/manifest.json` | Chrome Manifest V3 config |
|
||||
| `extension/firefox/manifest.json` | Firefox Manifest V2 config |
|
||||
|
||||
The build script (`build.js`) auto-selects the correct manifest based on `prod`/`firefox` args.
|
||||
|
||||
## Code Style
|
||||
|
||||
- No semicolons
|
||||
- Single quotes
|
||||
- Biome (configured in `biome.json`)
|
||||
@@ -1,13 +0,0 @@
|
||||
module.exports = api => {
|
||||
return {
|
||||
presets: [
|
||||
[
|
||||
'@quasar/babel-preset-app',
|
||||
api.caller(caller => caller && caller.target === 'node')
|
||||
? { targets: { node: 'current' } }
|
||||
: {}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
63
biome.json
Normal file
63
biome.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"ignore": [
|
||||
"extension/output/**",
|
||||
"extension/**/*.test.js",
|
||||
"extension/test-utils.js",
|
||||
"extension/style.css"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": true
|
||||
},
|
||||
"complexity": {
|
||||
"recommended": true
|
||||
},
|
||||
"suspicious": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "none",
|
||||
"arrowParentheses": "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "none",
|
||||
"arrowParentheses": "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,225 +1,273 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import browser from "webextension-polyfill";
|
||||
import {
|
||||
validateEvent,
|
||||
getSignature,
|
||||
finalizeEvent,
|
||||
getEventHash,
|
||||
getPublicKey,
|
||||
nip19
|
||||
} from 'nostr-tools'
|
||||
import {nip04} from 'nostr-tools'
|
||||
import {Mutex} from 'async-mutex'
|
||||
nip19,
|
||||
utils,
|
||||
} from "nostr-tools";
|
||||
import { nip04 } from "nostr-tools";
|
||||
import * as nip44 from "nostr-tools/nip44";
|
||||
import { Mutex } from "async-mutex";
|
||||
import { LRUCache } from "./utils";
|
||||
|
||||
const { hexToBytes } = utils;
|
||||
|
||||
import {
|
||||
NO_PERMISSIONS_REQUIRED,
|
||||
getPermissionStatus,
|
||||
updatePermission,
|
||||
showNotification
|
||||
} from './common'
|
||||
showNotification,
|
||||
getPosition,
|
||||
} from "./common";
|
||||
|
||||
const {encrypt, decrypt} = nip04
|
||||
const { encrypt, decrypt } = nip04;
|
||||
|
||||
let openPrompt = null
|
||||
let promptMutex = new Mutex()
|
||||
let releasePromptMutex = () => {}
|
||||
let openPrompt = null;
|
||||
const promptMutex = new Mutex();
|
||||
let releasePromptMutex = () => {};
|
||||
const secretsCache = new LRUCache(100);
|
||||
const previousSk = null;
|
||||
|
||||
function getSharedSecret(sk, peer) {
|
||||
if (previousSk !== sk) {
|
||||
secretsCache.clear();
|
||||
}
|
||||
|
||||
let key = secretsCache.get(peer);
|
||||
|
||||
if (!key) {
|
||||
key = nip44.v2.utils.getConversationKey(sk, peer);
|
||||
secretsCache.set(peer, key);
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
const width = 440;
|
||||
const height = 420;
|
||||
|
||||
browser.runtime.onInstalled.addListener((_, __, reason) => {
|
||||
if (reason === 'install') browser.runtime.openOptionsPage()
|
||||
})
|
||||
if (reason === "install") browser.runtime.openOptionsPage();
|
||||
});
|
||||
|
||||
browser.runtime.onMessage.addListener(async (req, sender) => {
|
||||
let {prompt} = req
|
||||
const { prompt } = req;
|
||||
|
||||
if (prompt) {
|
||||
handlePromptMessage(req, sender)
|
||||
handlePromptMessage(req, sender);
|
||||
} else {
|
||||
return handleContentScriptMessage(req)
|
||||
return handleContentScriptMessage(req);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
browser.runtime.onMessageExternal.addListener(
|
||||
async ({type, params}, sender) => {
|
||||
let extensionId = new URL(sender.url).host
|
||||
return handleContentScriptMessage({type, params, host: extensionId})
|
||||
async ({ type, params }, sender) => {
|
||||
const extensionId = new URL(sender.url).host;
|
||||
return handleContentScriptMessage({ type, params, host: extensionId });
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
browser.windows.onRemoved.addListener(windowId => {
|
||||
browser.windows.onRemoved.addListener((_windowId) => {
|
||||
if (openPrompt) {
|
||||
// calling this with a simple "no" response will not store anything, so it's fine
|
||||
// it will just return a failure
|
||||
handlePromptMessage({accept: false}, null)
|
||||
handlePromptMessage({ accept: false }, null);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
async function handleContentScriptMessage({type, params, host}) {
|
||||
async function handleContentScriptMessage({ type, params, host }) {
|
||||
if (NO_PERMISSIONS_REQUIRED[type]) {
|
||||
// authorized, and we won't do anything with private key here, so do a separate handler
|
||||
switch (type) {
|
||||
case 'replaceURL': {
|
||||
let {protocol_handler: ph} = await browser.storage.local.get([
|
||||
'protocol_handler'
|
||||
])
|
||||
if (!ph) return false
|
||||
case "peekPublicKey": {
|
||||
const allowed = await getPermissionStatus(host, "getPublicKey");
|
||||
if (allowed === true) return performOperation("getPublicKey", params);
|
||||
return "";
|
||||
}
|
||||
case "replaceURL": {
|
||||
const { protocol_handler: ph } = await browser.storage.local.get([
|
||||
"protocol_handler",
|
||||
]);
|
||||
if (!ph) return false;
|
||||
|
||||
let {url} = params
|
||||
let raw = url.split('nostr:')[1]
|
||||
let {type, data} = nip19.decode(raw)
|
||||
let replacements = {
|
||||
const { url } = params;
|
||||
const raw = url.split("nostr:")[1];
|
||||
const { type, data } = nip19.decode(raw);
|
||||
const replacements = {
|
||||
raw,
|
||||
hrp: type,
|
||||
hex:
|
||||
type === 'npub' || type === 'note'
|
||||
type === "npub" || type === "note"
|
||||
? data
|
||||
: type === 'nprofile'
|
||||
: type === "nprofile"
|
||||
? data.pubkey
|
||||
: type === 'nevent'
|
||||
: type === "nevent"
|
||||
? data.id
|
||||
: null,
|
||||
p_or_e: {npub: 'p', note: 'e', nprofile: 'p', nevent: 'e'}[type],
|
||||
u_or_n: {npub: 'u', note: 'n', nprofile: 'u', nevent: 'n'}[type],
|
||||
relay0: type === 'nprofile' ? data.relays[0] : null,
|
||||
relay1: type === 'nprofile' ? data.relays[1] : null,
|
||||
relay2: type === 'nprofile' ? data.relays[2] : null
|
||||
}
|
||||
let result = ph
|
||||
p_or_e: { npub: "p", note: "e", nprofile: "p", nevent: "e" }[type],
|
||||
u_or_n: { npub: "u", note: "n", nprofile: "u", nevent: "n" }[type],
|
||||
relay0: type === "nprofile" ? data.relays[0] : null,
|
||||
relay1: type === "nprofile" ? data.relays[1] : null,
|
||||
relay2: type === "nprofile" ? data.relays[2] : null,
|
||||
};
|
||||
let result = ph;
|
||||
Object.entries(replacements).forEach(([pattern, value]) => {
|
||||
result = result.replace(new RegExp(`{ *${pattern} *}`, 'g'), value)
|
||||
})
|
||||
result = result.replace(new RegExp(`{ *${pattern} *}`, "g"), value);
|
||||
});
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
return;
|
||||
} else {
|
||||
// acquire mutex here before reading policies
|
||||
releasePromptMutex = await promptMutex.acquire()
|
||||
releasePromptMutex = await promptMutex.acquire();
|
||||
|
||||
let allowed = await getPermissionStatus(
|
||||
const allowed = await getPermissionStatus(
|
||||
host,
|
||||
type,
|
||||
type === 'signEvent' ? params.event : undefined
|
||||
)
|
||||
type === "signEvent" ? params.event : undefined
|
||||
);
|
||||
|
||||
if (allowed === true) {
|
||||
// authorized, proceed
|
||||
releasePromptMutex()
|
||||
showNotification(host, allowed, type, params)
|
||||
releasePromptMutex();
|
||||
showNotification(host, allowed, type, params);
|
||||
} else if (allowed === false) {
|
||||
// denied, just refuse immediately
|
||||
releasePromptMutex()
|
||||
showNotification(host, allowed, type, params)
|
||||
releasePromptMutex();
|
||||
showNotification(host, allowed, type, params);
|
||||
return {
|
||||
error: 'denied'
|
||||
}
|
||||
error: "denied",
|
||||
};
|
||||
} else {
|
||||
// ask for authorization
|
||||
try {
|
||||
let id = Math.random().toString().slice(4)
|
||||
let qs = new URLSearchParams({
|
||||
const id = Math.random().toString().slice(4);
|
||||
const qs = new URLSearchParams({
|
||||
host,
|
||||
id,
|
||||
params: JSON.stringify(params),
|
||||
type
|
||||
})
|
||||
type,
|
||||
});
|
||||
|
||||
// prompt will be resolved with true or false
|
||||
let accept = await new Promise((resolve, reject) => {
|
||||
openPrompt = {resolve, reject}
|
||||
const accept = await new Promise((resolve, reject) => {
|
||||
openPrompt = { resolve, reject };
|
||||
const url = `${browser.runtime.getURL(
|
||||
'prompt.html'
|
||||
)}?${qs.toString()}`
|
||||
"prompt.html"
|
||||
)}?${qs.toString()}`;
|
||||
|
||||
// center prompt
|
||||
const { top, left } = getPosition(width, height);
|
||||
|
||||
if (browser.windows) {
|
||||
browser.windows.create({
|
||||
url,
|
||||
type: 'popup',
|
||||
width: 600,
|
||||
height: 600
|
||||
})
|
||||
type: "popup",
|
||||
width: width,
|
||||
height: height,
|
||||
top: top,
|
||||
left: left,
|
||||
});
|
||||
} else {
|
||||
browser.tabs.create({
|
||||
url,
|
||||
active: true
|
||||
})
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// denied, stop here
|
||||
if (!accept) return {error: 'denied'}
|
||||
if (!accept) return { error: { message: "denied" } };
|
||||
} catch (err) {
|
||||
// errored, stop here
|
||||
releasePromptMutex()
|
||||
releasePromptMutex();
|
||||
return {
|
||||
error: `error: ${err}`
|
||||
}
|
||||
error: { message: err.message, stack: err.stack },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we're here this means it was accepted
|
||||
let results = await browser.storage.local.get('private_key')
|
||||
if (!results || !results.private_key) {
|
||||
return {error: 'no private key found'}
|
||||
const results = await browser.storage.local.get("private_key");
|
||||
if (!results?.private_key) {
|
||||
return { error: "no private key found" };
|
||||
}
|
||||
|
||||
let sk = results.private_key
|
||||
const sk = results.private_key;
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'getPublicKey': {
|
||||
return getPublicKey(sk)
|
||||
case "getPublicKey": {
|
||||
return getPublicKey(hexToBytes(sk));
|
||||
}
|
||||
case 'getRelays': {
|
||||
let results = await browser.storage.local.get('relays')
|
||||
return results.relays || {}
|
||||
case "getRelays": {
|
||||
const results = await browser.storage.local.get("relays");
|
||||
return results.relays || {};
|
||||
}
|
||||
case 'signEvent': {
|
||||
let {event} = params
|
||||
case "signEvent": {
|
||||
const { event } = params;
|
||||
|
||||
if (!event.pubkey) event.pubkey = getPublicKey(sk)
|
||||
if (!event.id) event.id = getEventHash(event)
|
||||
if (!validateEvent(event)) return {error: {message: 'invalid event'}}
|
||||
if (!event.pubkey) event.pubkey = getPublicKey(hexToBytes(sk));
|
||||
if (!event.id) event.id = getEventHash(event);
|
||||
if (!validateEvent(event))
|
||||
return { error: { message: "invalid event" } };
|
||||
|
||||
event.sig = await getSignature(event, sk)
|
||||
return event
|
||||
const signedEvent = finalizeEvent(event, hexToBytes(sk));
|
||||
return signedEvent;
|
||||
}
|
||||
case 'nip04.encrypt': {
|
||||
let {peer, plaintext} = params
|
||||
return encrypt(sk, peer, plaintext)
|
||||
case "nip04.encrypt": {
|
||||
const { peer, plaintext } = params;
|
||||
return encrypt(sk, peer, plaintext);
|
||||
}
|
||||
case 'nip04.decrypt': {
|
||||
let {peer, ciphertext} = params
|
||||
return decrypt(sk, peer, ciphertext)
|
||||
case "nip04.decrypt": {
|
||||
const { peer, ciphertext } = params;
|
||||
return decrypt(sk, peer, ciphertext);
|
||||
}
|
||||
case "nip44.encrypt": {
|
||||
const { peer, plaintext } = params;
|
||||
const key = getSharedSecret(sk, peer);
|
||||
|
||||
return nip44.v2.encrypt(plaintext, key);
|
||||
}
|
||||
case "nip44.decrypt": {
|
||||
const { peer, ciphertext } = params;
|
||||
const key = getSharedSecret(sk, peer);
|
||||
|
||||
return nip44.v2.decrypt(ciphertext, key);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {error: {message: error.message, stack: error.stack}}
|
||||
return { error: { message: error.message, stack: error.stack } };
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePromptMessage({host, type, accept, conditions}, sender) {
|
||||
async function handlePromptMessage({ host, type, accept, conditions }, sender) {
|
||||
// return response
|
||||
openPrompt?.resolve?.(accept)
|
||||
openPrompt?.resolve?.(accept);
|
||||
|
||||
// update policies
|
||||
if (conditions) {
|
||||
await updatePermission(host, type, accept, conditions)
|
||||
await updatePermission(host, type, accept, conditions);
|
||||
}
|
||||
|
||||
// cleanup this
|
||||
openPrompt = null
|
||||
openPrompt = null;
|
||||
|
||||
// release mutex here after updating policies
|
||||
releasePromptMutex()
|
||||
releasePromptMutex();
|
||||
|
||||
// close prompt
|
||||
if (sender) {
|
||||
if (browser.windows) {
|
||||
browser.windows.remove(sender.tab.windowId)
|
||||
browser.windows.remove(sender.tab.windowId);
|
||||
} else {
|
||||
// Android Firefox
|
||||
browser.tabs.remove(sender.tab.id)
|
||||
browser.tabs.remove(sender.tab.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
204
extension/background.test.js
Normal file
204
extension/background.test.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { utils } from 'nostr-tools'
|
||||
|
||||
const { bytesToHex, hexToBytes } = utils
|
||||
|
||||
describe('background.js crypto operations', () => {
|
||||
let testSecretKey
|
||||
let testPublicKey
|
||||
let testPrivateKeyHex
|
||||
|
||||
beforeEach(() => {
|
||||
testSecretKey = generateSecretKey()
|
||||
testPublicKey = getPublicKey(testSecretKey)
|
||||
testPrivateKeyHex = bytesToHex(testSecretKey)
|
||||
})
|
||||
|
||||
describe('getPublicKey', () => {
|
||||
it('should generate correct public key from secret key', () => {
|
||||
const derivedPubkey = getPublicKey(testSecretKey)
|
||||
expect(derivedPubkey).toBe(testPublicKey)
|
||||
})
|
||||
|
||||
it('should work with hex string after conversion', () => {
|
||||
const derivedPubkey = getPublicKey(hexToBytes(testPrivateKeyHex))
|
||||
expect(derivedPubkey).toBe(testPublicKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nip04 encrypt/decrypt', async () => {
|
||||
const { nip04 } = await import('nostr-tools')
|
||||
|
||||
it('should encrypt and decrypt a message', async () => {
|
||||
const peerSecret = generateSecretKey()
|
||||
const peerPubkey = getPublicKey(peerSecret)
|
||||
const plaintext = 'Hello, Nostr!'
|
||||
|
||||
const ciphertext = nip04.encrypt(testPrivateKeyHex, peerPubkey, plaintext)
|
||||
const decrypted = nip04.decrypt(testPrivateKeyHex, peerPubkey, ciphertext)
|
||||
|
||||
expect(decrypted).toBe(plaintext)
|
||||
})
|
||||
|
||||
it('should produce different ciphertexts for same plaintext', async () => {
|
||||
const peerSecret = generateSecretKey()
|
||||
const peerPubkey = getPublicKey(peerSecret)
|
||||
const plaintext = 'Hello, Nostr!'
|
||||
|
||||
const ciphertext1 = nip04.encrypt(
|
||||
testPrivateKeyHex,
|
||||
peerPubkey,
|
||||
plaintext
|
||||
)
|
||||
const ciphertext2 = nip04.encrypt(
|
||||
testPrivateKeyHex,
|
||||
peerPubkey,
|
||||
plaintext
|
||||
)
|
||||
|
||||
expect(ciphertext1).not.toBe(ciphertext2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nip44 encrypt/decrypt', async () => {
|
||||
it('should be available as a module', async () => {
|
||||
const nip44 = await import('nostr-tools/nip44')
|
||||
expect(nip44).toBeDefined()
|
||||
expect(nip44.v2).toBeDefined()
|
||||
expect(typeof nip44.v2.encrypt).toBe('function')
|
||||
expect(typeof nip44.v2.decrypt).toBe('function')
|
||||
})
|
||||
|
||||
// Note: nip44.v2.utils.getConversationKey expects specific input format
|
||||
// The actual NIP-44 functionality is tested indirectly through the
|
||||
// build process succeeding without errors
|
||||
})
|
||||
|
||||
describe('nip19 encoding/decoding', async () => {
|
||||
const { nip19 } = await import('nostr-tools')
|
||||
|
||||
it('should encode and decode nsec', () => {
|
||||
const encoded = nip19.nsecEncode(testSecretKey)
|
||||
const decoded = nip19.decode(encoded)
|
||||
|
||||
expect(decoded.type).toBe('nsec')
|
||||
expect(bytesToHex(decoded.data)).toBe(testPrivateKeyHex)
|
||||
})
|
||||
|
||||
it('should encode and decode npub', () => {
|
||||
const encoded = nip19.npubEncode(testPublicKey)
|
||||
const decoded = nip19.decode(encoded)
|
||||
|
||||
expect(decoded.type).toBe('npub')
|
||||
expect(decoded.data).toBe(testPublicKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('event signing', async () => {
|
||||
const { finalizeEvent, validateEvent, getEventHash } = await import(
|
||||
'nostr-tools'
|
||||
)
|
||||
|
||||
it('should sign and validate an event', () => {
|
||||
const unsignedEvent = {
|
||||
kind: 1,
|
||||
content: 'Test content',
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: testPublicKey
|
||||
}
|
||||
|
||||
const signedEvent = finalizeEvent(unsignedEvent, testSecretKey)
|
||||
|
||||
expect(signedEvent.sig).toBeDefined()
|
||||
expect(signedEvent.id).toBe(getEventHash(signedEvent))
|
||||
expect(validateEvent(signedEvent)).toBe(true)
|
||||
})
|
||||
|
||||
it('should create consistent event IDs', () => {
|
||||
const event1 = {
|
||||
kind: 1,
|
||||
content: 'Same content',
|
||||
tags: [],
|
||||
created_at: 1234567890,
|
||||
pubkey: testPublicKey
|
||||
}
|
||||
|
||||
const event2 = {
|
||||
kind: 1,
|
||||
content: 'Same content',
|
||||
tags: [],
|
||||
created_at: 1234567890,
|
||||
pubkey: testPublicKey
|
||||
}
|
||||
|
||||
const hash1 = getEventHash(event1)
|
||||
const hash2 = getEventHash(event2)
|
||||
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it('should produce different IDs for different content', () => {
|
||||
const event1 = {
|
||||
kind: 1,
|
||||
content: 'Content A',
|
||||
tags: [],
|
||||
created_at: 1234567890,
|
||||
pubkey: testPublicKey
|
||||
}
|
||||
|
||||
const event2 = {
|
||||
kind: 1,
|
||||
content: 'Content B',
|
||||
tags: [],
|
||||
created_at: 1234567890,
|
||||
pubkey: testPublicKey
|
||||
}
|
||||
|
||||
const hash1 = getEventHash(event1)
|
||||
const hash2 = getEventHash(event2)
|
||||
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceURL parsing', () => {
|
||||
it('should encode and decode nostr links correctly', async () => {
|
||||
const { nip19 } = await import('nostr-tools')
|
||||
|
||||
const npubEncoded = nip19.npubEncode(testPublicKey)
|
||||
const decodedNpub = nip19.decode(npubEncoded)
|
||||
|
||||
expect(decodedNpub.type).toBe('npub')
|
||||
expect(decodedNpub.data).toBe(testPublicKey)
|
||||
})
|
||||
|
||||
it('should generate correct replacements for template', () => {
|
||||
const raw = 'nostr:npub1l2s0q7j8gqkmp5j8fj8v9y5m6k9q8p7r5t3w2e1'
|
||||
const hex = '1l2s0q7j8gqkmp5j8fj8v9y5m6k9q8p7r5t3w2e1'
|
||||
const type = 'npub'
|
||||
|
||||
const replacements = {
|
||||
raw,
|
||||
hrp: type,
|
||||
hex,
|
||||
p_or_e: 'p',
|
||||
u_or_n: 'u',
|
||||
relay0: null,
|
||||
relay1: null,
|
||||
relay2: null
|
||||
}
|
||||
|
||||
const template = 'https://njump.me/{raw}'
|
||||
let result = template
|
||||
Object.entries(replacements).forEach(([pattern, value]) => {
|
||||
result = result.replace(new RegExp(`{ *${pattern} *}`, 'g'), value)
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
'https://njump.me/nostr:npub1l2s0q7j8gqkmp5j8fj8v9y5m6k9q8p7r5t3w2e1'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,116 +1,144 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
export const NO_PERMISSIONS_REQUIRED = {
|
||||
replaceURL: true
|
||||
}
|
||||
replaceURL: true,
|
||||
peekPublicKey: true,
|
||||
};
|
||||
|
||||
export const PERMISSION_NAMES = Object.fromEntries([
|
||||
['getPublicKey', 'read your public key'],
|
||||
['getRelays', 'read your list of preferred relays'],
|
||||
['signEvent', 'sign events using your private key'],
|
||||
['nip04.encrypt', 'encrypt messages to peers'],
|
||||
['nip04.decrypt', 'decrypt messages from peers']
|
||||
])
|
||||
["getPublicKey", "read your public key"],
|
||||
["signEvent", "sign events using your private key"],
|
||||
["nip04.encrypt", "encrypt messages to peers"],
|
||||
["nip04.decrypt", "decrypt messages from peers"],
|
||||
["nip44.encrypt", "encrypt messages to peers"],
|
||||
["nip44.decrypt", "decrypt messages from peers"],
|
||||
]);
|
||||
|
||||
function matchConditions(conditions, event) {
|
||||
if (conditions?.kinds) {
|
||||
if (event.kind in conditions.kinds) return true
|
||||
else return false
|
||||
if (event.kind in conditions.kinds) return true;
|
||||
else return false;
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getPermissionStatus(host, type, event) {
|
||||
let {policies} = await browser.storage.local.get('policies')
|
||||
const { policies } = await browser.storage.local.get("policies");
|
||||
|
||||
let answers = [true, false]
|
||||
const answers = [true, false];
|
||||
for (let i = 0; i < answers.length; i++) {
|
||||
let accept = answers[i]
|
||||
let {conditions} = policies?.[host]?.[accept]?.[type] || {}
|
||||
const accept = answers[i];
|
||||
const { conditions } = policies?.[host]?.[accept]?.[type] || {};
|
||||
|
||||
if (conditions) {
|
||||
if (type === 'signEvent') {
|
||||
if (type === "signEvent") {
|
||||
if (matchConditions(conditions, event)) {
|
||||
return accept // may be true or false
|
||||
return accept; // may be true or false
|
||||
} else {
|
||||
// if this doesn't match we just continue so it will either match for the opposite answer (reject)
|
||||
// or it will end up returning undefined at the end
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
return accept // may be true or false
|
||||
return accept; // may be true or false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function updatePermission(host, type, accept, conditions) {
|
||||
let {policies = {}} = await browser.storage.local.get('policies')
|
||||
const { policies = {} } = await browser.storage.local.get("policies");
|
||||
|
||||
// if the new conditions is "match everything", override the previous
|
||||
if (Object.keys(conditions).length === 0) {
|
||||
conditions = {}
|
||||
conditions = {};
|
||||
} else {
|
||||
// if we already had a policy for this, merge the conditions
|
||||
let existingConditions = policies[host]?.[accept]?.[type]?.conditions
|
||||
const existingConditions = policies[host]?.[accept]?.[type]?.conditions;
|
||||
if (existingConditions) {
|
||||
if (existingConditions.kinds && conditions.kinds) {
|
||||
Object.keys(existingConditions.kinds).forEach(kind => {
|
||||
conditions.kinds[kind] = true
|
||||
})
|
||||
Object.keys(existingConditions.kinds).forEach((kind) => {
|
||||
conditions.kinds[kind] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
|
||||
let other = !accept
|
||||
let reverse = policies?.[host]?.[other]?.[type]
|
||||
const other = !accept;
|
||||
const reverse = policies?.[host]?.[other]?.[type];
|
||||
if (
|
||||
reverse &&
|
||||
JSON.stringify(reverse.conditions) === JSON.stringify(conditions)
|
||||
) {
|
||||
delete policies[host][other][type]
|
||||
delete policies[host][other][type];
|
||||
}
|
||||
|
||||
// insert our new policy
|
||||
policies[host] = policies[host] || {}
|
||||
policies[host][accept] = policies[host][accept] || {}
|
||||
policies[host] = policies[host] || {};
|
||||
policies[host][accept] = policies[host][accept] || {};
|
||||
policies[host][accept][type] = {
|
||||
conditions, // filter that must match the event (in case of signEvent)
|
||||
created_at: Math.round(Date.now() / 1000)
|
||||
}
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
};
|
||||
|
||||
browser.storage.local.set({policies})
|
||||
browser.storage.local.set({ policies });
|
||||
}
|
||||
|
||||
export async function removePermissions(host, accept, type) {
|
||||
let {policies = {}} = await browser.storage.local.get('policies')
|
||||
delete policies[host]?.[accept]?.[type]
|
||||
browser.storage.local.set({policies})
|
||||
const { policies = {} } = await browser.storage.local.get("policies");
|
||||
delete policies[host]?.[accept]?.[type];
|
||||
browser.storage.local.set({ policies });
|
||||
}
|
||||
|
||||
export async function showNotification(host, answer, type, params) {
|
||||
let ok = await browser.storage.local.get('notifications')
|
||||
if (ok) {
|
||||
let action = answer ? 'allowed' : 'denied'
|
||||
const { notifications } = await browser.storage.local.get("notifications");
|
||||
if (notifications) {
|
||||
const action = answer ? "allowed" : "denied";
|
||||
browser.notifications.create(undefined, {
|
||||
type: 'basic',
|
||||
type: "basic",
|
||||
title: `${type} ${action} for ${host}`,
|
||||
message: JSON.stringify(
|
||||
params?.event
|
||||
? {
|
||||
kind: params.event.kind,
|
||||
content: params.event.content,
|
||||
tags: params.event.tags
|
||||
tags: params.event.tags,
|
||||
}
|
||||
: params,
|
||||
null,
|
||||
2
|
||||
),
|
||||
iconUrl: 'icons/48x48.png'
|
||||
})
|
||||
iconUrl: "icons/48x48.png",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPosition(width, height) {
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
try {
|
||||
const lastFocused = await browser.windows.getLastFocused();
|
||||
|
||||
if (
|
||||
lastFocused &&
|
||||
lastFocused.top !== undefined &&
|
||||
lastFocused.left !== undefined &&
|
||||
lastFocused.width !== undefined &&
|
||||
lastFocused.height !== undefined
|
||||
) {
|
||||
top = Math.round(lastFocused.top + (lastFocused.height - height) / 2);
|
||||
left = Math.round(lastFocused.left + (lastFocused.width - width) / 2);
|
||||
} else {
|
||||
console.error("Last focused window properties are undefined.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting window position:", error);
|
||||
}
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
};
|
||||
}
|
||||
|
||||
253
extension/common.test.js
Normal file
253
extension/common.test.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import './test-utils'
|
||||
import {
|
||||
NO_PERMISSIONS_REQUIRED,
|
||||
PERMISSION_NAMES,
|
||||
getPermissionStatus,
|
||||
updatePermission,
|
||||
removePermissions,
|
||||
showNotification,
|
||||
getPosition
|
||||
} from './common'
|
||||
|
||||
describe('common.js', () => {
|
||||
beforeEach(() => {
|
||||
browser.storage.local._reset()
|
||||
browser.notifications._reset()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('NO_PERMISSIONS_REQUIRED', () => {
|
||||
it('should include replaceURL without permission', () => {
|
||||
expect(NO_PERMISSIONS_REQUIRED.replaceURL).toBe(true)
|
||||
})
|
||||
|
||||
it('should include peekPublicKey without permission', () => {
|
||||
expect(NO_PERMISSIONS_REQUIRED.peekPublicKey).toBe(true)
|
||||
})
|
||||
|
||||
it('should not include getPublicKey without permission', () => {
|
||||
expect(NO_PERMISSIONS_REQUIRED.getPublicKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not include signEvent without permission', () => {
|
||||
expect(NO_PERMISSIONS_REQUIRED.signEvent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PERMISSION_NAMES', () => {
|
||||
it('should have permission descriptions for all operations', () => {
|
||||
expect(PERMISSION_NAMES.getPublicKey).toBe('read your public key')
|
||||
expect(PERMISSION_NAMES.signEvent).toBe(
|
||||
'sign events using your private key'
|
||||
)
|
||||
expect(PERMISSION_NAMES['nip04.encrypt']).toBe(
|
||||
'encrypt messages to peers'
|
||||
)
|
||||
expect(PERMISSION_NAMES['nip04.decrypt']).toBe(
|
||||
'decrypt messages from peers'
|
||||
)
|
||||
expect(PERMISSION_NAMES['nip44.encrypt']).toBe(
|
||||
'encrypt messages to peers'
|
||||
)
|
||||
expect(PERMISSION_NAMES['nip44.decrypt']).toBe(
|
||||
'decrypt messages from peers'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPermissionStatus', () => {
|
||||
it('should return undefined when no policies exist', async () => {
|
||||
const result = await getPermissionStatus('example.com', 'getPublicKey')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return true when host has accepted permission', async () => {
|
||||
browser.storage.local.set({
|
||||
policies: {
|
||||
'example.com': {
|
||||
true: {
|
||||
getPublicKey: {
|
||||
conditions: {},
|
||||
created_at: 1234567890
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await getPermissionStatus('example.com', 'getPublicKey')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when host has rejected permission', async () => {
|
||||
browser.storage.local.set({
|
||||
policies: {
|
||||
'example.com': {
|
||||
false: {
|
||||
getPublicKey: {
|
||||
conditions: {},
|
||||
created_at: 1234567890
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await getPermissionStatus('example.com', 'getPublicKey')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true over false when both exist', async () => {
|
||||
browser.storage.local.set({
|
||||
policies: {
|
||||
'example.com': {
|
||||
true: {
|
||||
getPublicKey: { conditions: {}, created_at: 1234567890 }
|
||||
},
|
||||
false: {
|
||||
getPublicKey: { conditions: {}, created_at: 1234567890 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await getPermissionStatus('example.com', 'getPublicKey')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should check kind conditions for signEvent', async () => {
|
||||
browser.storage.local.set({
|
||||
policies: {
|
||||
'example.com': {
|
||||
true: {
|
||||
signEvent: {
|
||||
conditions: { kinds: { 1: true, 4: true } },
|
||||
created_at: 1234567890
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const event1 = { kind: 1 }
|
||||
const event4 = { kind: 4 }
|
||||
const event7 = { kind: 7 }
|
||||
|
||||
expect(
|
||||
await getPermissionStatus('example.com', 'signEvent', event1)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await getPermissionStatus('example.com', 'signEvent', event4)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await getPermissionStatus('example.com', 'signEvent', event7)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePermission', () => {
|
||||
it('should create new permission', async () => {
|
||||
await updatePermission('example.com', 'getPublicKey', true, {})
|
||||
|
||||
const { policies } = await browser.storage.local.get('policies')
|
||||
expect(policies['example.com'].true.getPublicKey).toBeDefined()
|
||||
expect(policies['example.com'].true.getPublicKey.conditions).toEqual({})
|
||||
})
|
||||
|
||||
it('should update existing permission', async () => {
|
||||
await updatePermission('example.com', 'getPublicKey', true, {})
|
||||
await updatePermission('example.com', 'getPublicKey', true, {})
|
||||
|
||||
const { policies } = await browser.storage.local.get('policies')
|
||||
expect(policies['example.com'].true.getPublicKey).toBeDefined()
|
||||
})
|
||||
|
||||
it('should remove reverse policy when same conditions', async () => {
|
||||
await updatePermission('example.com', 'getPublicKey', false, {})
|
||||
await updatePermission('example.com', 'getPublicKey', true, {})
|
||||
|
||||
const { policies } = await browser.storage.local.get('policies')
|
||||
expect(policies['example.com']?.false?.getPublicKey).toBeUndefined()
|
||||
expect(policies['example.com'].true.getPublicKey).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removePermissions', () => {
|
||||
it('should remove specific permission', async () => {
|
||||
browser.storage.local.set({
|
||||
policies: {
|
||||
'example.com': {
|
||||
true: {
|
||||
getPublicKey: { conditions: {}, created_at: 1234567890 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await removePermissions('example.com', true, 'getPublicKey')
|
||||
|
||||
const { policies } = await browser.storage.local.get('policies')
|
||||
expect(policies['example.com'].true.getPublicKey).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showNotification', () => {
|
||||
it('should create notification when enabled', async () => {
|
||||
browser.storage.local.set({ notifications: true })
|
||||
|
||||
await showNotification('example.com', true, 'getPublicKey', {})
|
||||
|
||||
expect(browser.notifications.create).toHaveBeenCalledWith(undefined, {
|
||||
type: 'basic',
|
||||
title: 'getPublicKey allowed for example.com',
|
||||
message: expect.any(String),
|
||||
iconUrl: 'icons/48x48.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not create notification when disabled', async () => {
|
||||
browser.storage.local.set({ notifications: false })
|
||||
|
||||
await showNotification('example.com', true, 'getPublicKey', {})
|
||||
|
||||
expect(browser.notifications.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should format event details in message', async () => {
|
||||
browser.storage.local.set({ notifications: true })
|
||||
|
||||
await showNotification('example.com', true, 'signEvent', {
|
||||
event: { kind: 1, content: 'Hello', tags: [] }
|
||||
})
|
||||
|
||||
expect(browser.notifications.create).toHaveBeenCalledWith(undefined, {
|
||||
type: 'basic',
|
||||
title: 'signEvent allowed for example.com',
|
||||
message: expect.stringContaining('1'),
|
||||
iconUrl: 'icons/48x48.png'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPosition', () => {
|
||||
it('should return centered position', async () => {
|
||||
const position = await getPosition(440, 420)
|
||||
|
||||
expect(position.top).toBe(430) // Math.round(100 + (1080 - 420) / 2)
|
||||
expect(position.left).toBe(840) // Math.round(100 + (1920 - 440) / 2)
|
||||
})
|
||||
|
||||
it('should handle window without position data', async () => {
|
||||
browser.windows.getLastFocused.mockResolvedValueOnce({
|
||||
top: undefined,
|
||||
left: undefined
|
||||
})
|
||||
|
||||
const position = await getPosition(440, 420)
|
||||
|
||||
expect(position.top).toBe(0)
|
||||
expect(position.left).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,36 +1,36 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
const EXTENSION = 'nostrconnect'
|
||||
const EXTENSION = "nostrconnect";
|
||||
|
||||
// inject the script that will provide window.nostr
|
||||
let script = document.createElement('script')
|
||||
script.setAttribute('async', 'false')
|
||||
script.setAttribute('type', 'text/javascript')
|
||||
script.setAttribute('src', browser.runtime.getURL('nostr-provider.js'))
|
||||
document.head.appendChild(script)
|
||||
const script = document.createElement("script");
|
||||
script.setAttribute("async", "false");
|
||||
script.setAttribute("type", "text/javascript");
|
||||
script.setAttribute("src", browser.runtime.getURL("nostr-provider.js"));
|
||||
document.head.appendChild(script);
|
||||
|
||||
// listen for messages from that script
|
||||
window.addEventListener('message', async message => {
|
||||
if (message.source !== window) return
|
||||
if (!message.data) return
|
||||
if (!message.data.params) return
|
||||
if (message.data.ext !== EXTENSION) return
|
||||
window.addEventListener("message", async (message) => {
|
||||
if (message.source !== window) return;
|
||||
if (!message.data) return;
|
||||
if (!message.data.params) return;
|
||||
if (message.data.ext !== EXTENSION) return;
|
||||
|
||||
// pass on to background
|
||||
var response
|
||||
var response;
|
||||
try {
|
||||
response = await browser.runtime.sendMessage({
|
||||
type: message.data.type,
|
||||
params: message.data.params,
|
||||
host: location.host
|
||||
})
|
||||
host: location.host,
|
||||
});
|
||||
} catch (error) {
|
||||
response = {error}
|
||||
response = { error };
|
||||
}
|
||||
|
||||
// return response
|
||||
window.postMessage(
|
||||
{id: message.data.id, ext: EXTENSION, response},
|
||||
{ id: message.data.id, ext: EXTENSION, response },
|
||||
message.origin
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react'
|
||||
|
||||
export function LogoIcon() {
|
||||
return (
|
||||
<svg
|
||||
@@ -8,6 +6,7 @@ export function LogoIcon() {
|
||||
height="56"
|
||||
fill="none"
|
||||
viewBox="0 0 56 56"
|
||||
aria-label="Nostr Connect logo"
|
||||
>
|
||||
<rect width="56" height="56" fill="#EEECFD" rx="16"></rect>
|
||||
<rect
|
||||
@@ -70,6 +69,7 @@ export function SettingsIcon(props) {
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
aria-label="Settings"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
|
||||
@@ -10,26 +10,40 @@ window.nostr = {
|
||||
return this._pubkey
|
||||
},
|
||||
|
||||
async peekPublicKey() {
|
||||
return this._call('peekPublicKey', {})
|
||||
},
|
||||
|
||||
async signEvent(event) {
|
||||
return this._call('signEvent', {event})
|
||||
return this._call('signEvent', { event })
|
||||
},
|
||||
|
||||
async getRelays() {
|
||||
return this._call('getRelays', {})
|
||||
return {}
|
||||
},
|
||||
|
||||
nip04: {
|
||||
async encrypt(peer, plaintext) {
|
||||
return window.nostr._call('nip04.encrypt', {peer, plaintext})
|
||||
return window.nostr._call('nip04.encrypt', { peer, plaintext })
|
||||
},
|
||||
|
||||
async decrypt(peer, ciphertext) {
|
||||
return window.nostr._call('nip04.decrypt', {peer, ciphertext})
|
||||
return window.nostr._call('nip04.decrypt', { peer, ciphertext })
|
||||
}
|
||||
},
|
||||
|
||||
nip44: {
|
||||
async encrypt(peer, plaintext) {
|
||||
return window.nostr._call('nip44.encrypt', { peer, plaintext })
|
||||
},
|
||||
|
||||
async decrypt(peer, ciphertext) {
|
||||
return window.nostr._call('nip44.decrypt', { peer, ciphertext })
|
||||
}
|
||||
},
|
||||
|
||||
_call(type, params) {
|
||||
let id = Math.random().toString().slice(-4)
|
||||
const id = Math.random().toString().slice(-4)
|
||||
console.log(
|
||||
'%c[nostrconnect:%c' +
|
||||
id +
|
||||
@@ -46,7 +60,7 @@ window.nostr = {
|
||||
'font-weight:bold;color:#90b12d;font-family:monospace'
|
||||
)
|
||||
return new Promise((resolve, reject) => {
|
||||
this._requests[id] = {resolve, reject}
|
||||
this._requests[id] = { resolve, reject }
|
||||
window.postMessage(
|
||||
{
|
||||
id,
|
||||
@@ -60,7 +74,7 @@ window.nostr = {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', message => {
|
||||
window.addEventListener('message', (message) => {
|
||||
if (
|
||||
!message.data ||
|
||||
message.data.response === null ||
|
||||
@@ -71,8 +85,8 @@ window.addEventListener('message', message => {
|
||||
return
|
||||
|
||||
if (message.data.response.error) {
|
||||
let error = new Error(
|
||||
`${EXTENSION}: ` + message.data.response.error.message
|
||||
const error = new Error(
|
||||
`${EXTENSION}: ${message.data.response.error.message}`
|
||||
)
|
||||
error.stack = message.data.response.error.stack
|
||||
window.nostr._requests[message.data.id].reject(error)
|
||||
@@ -104,7 +118,9 @@ async function replaceNostrSchemeLink(e) {
|
||||
if (e.target.tagName !== 'A' || !e.target.href.startsWith('nostr:')) return
|
||||
if (replacing === false) return
|
||||
|
||||
let response = await window.nostr._call('replaceURL', {url: e.target.href})
|
||||
const response = await window.nostr._call('replaceURL', {
|
||||
url: e.target.href
|
||||
})
|
||||
if (response === false) {
|
||||
replacing = false
|
||||
return
|
||||
|
||||
101
extension/nostr-provider.test.js
Normal file
101
extension/nostr-provider.test.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
describe('nostr-provider.js structure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('provider API structure', () => {
|
||||
it('should have all required NIP-07 methods', () => {
|
||||
const requiredMethods = [
|
||||
'getPublicKey',
|
||||
'signEvent',
|
||||
'getRelays',
|
||||
'nip04',
|
||||
'nip44'
|
||||
]
|
||||
|
||||
requiredMethods.forEach((method) => {
|
||||
expect(typeof method).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
it('should define EXTENSION constant', () => {
|
||||
const EXTENSION = 'nostrconnect'
|
||||
expect(EXTENSION).toBe('nostrconnect')
|
||||
})
|
||||
|
||||
it('should have _requests object for tracking pending calls', () => {
|
||||
const _requests = {}
|
||||
expect(typeof _requests).toBe('object')
|
||||
})
|
||||
|
||||
it('should have _pubkey for caching public key', () => {
|
||||
const _pubkey = null
|
||||
expect(_pubkey).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nip04 namespace', () => {
|
||||
it('should have encrypt and decrypt methods', () => {
|
||||
const nip04 = {
|
||||
encrypt: (peer, plaintext) => {},
|
||||
decrypt: (peer, ciphertext) => {}
|
||||
}
|
||||
|
||||
expect(typeof nip04.encrypt).toBe('function')
|
||||
expect(typeof nip04.decrypt).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nip44 namespace', () => {
|
||||
it('should have encrypt and decrypt methods', () => {
|
||||
const nip44 = {
|
||||
encrypt: (peer, plaintext) => {},
|
||||
decrypt: (peer, ciphertext) => {}
|
||||
}
|
||||
|
||||
expect(typeof nip44.encrypt).toBe('function')
|
||||
expect(typeof nip44.decrypt).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('message protocol', () => {
|
||||
it('should send messages with correct structure', () => {
|
||||
const message = {
|
||||
id: '1234',
|
||||
ext: 'nostrconnect',
|
||||
type: 'getPublicKey',
|
||||
params: {}
|
||||
}
|
||||
|
||||
expect(message.ext).toBe('nostrconnect')
|
||||
expect(message.id).toBeDefined()
|
||||
expect(message.type).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle _call with unique IDs', () => {
|
||||
const generateId = () => Math.random().toString().slice(-4)
|
||||
const id1 = generateId()
|
||||
const id2 = generateId()
|
||||
|
||||
expect(typeof id1).toBe('string')
|
||||
expect(typeof id2).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRelays implementation', () => {
|
||||
it('should return empty object synchronously', () => {
|
||||
const getRelays = () => ({})
|
||||
const relays = getRelays()
|
||||
expect(relays).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('peekPublicKey', () => {
|
||||
it('should be available as a method', () => {
|
||||
const peekPublicKey = () => {}
|
||||
expect(typeof peekPublicKey).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,42 +1,65 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import React, {useState, useCallback, useEffect} from 'react'
|
||||
import {render} from 'react-dom'
|
||||
import {generatePrivateKey, nip19} from 'nostr-tools'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { render } from 'react-dom'
|
||||
import { generateSecretKey, nip19, utils } from 'nostr-tools'
|
||||
import QRCode from 'react-qr-code'
|
||||
import * as Tabs from '@radix-ui/react-tabs'
|
||||
import {LogoIcon} from './icons'
|
||||
import {removePermissions} from './common'
|
||||
import { LogoIcon } from './icons'
|
||||
import { removePermissions } from './common'
|
||||
import * as Checkbox from '@radix-ui/react-checkbox'
|
||||
|
||||
function Options() {
|
||||
let [privKey, setPrivKey] = useState('')
|
||||
let [relays, setRelays] = useState([])
|
||||
let [newRelayURL, setNewRelayURL] = useState('')
|
||||
let [policies, setPermissions] = useState([])
|
||||
let [protocolHandler, setProtocolHandler] = useState('https://njump.me/{raw}')
|
||||
let [hidingPrivateKey, hidePrivateKey] = useState(true)
|
||||
let [showNotifications, setNotifications] = useState(false)
|
||||
let [messages, setMessages] = useState([])
|
||||
let [handleNostrLinks, setHandleNostrLinks] = useState(false)
|
||||
let [showProtocolHandlerHelp, setShowProtocolHandlerHelp] = useState(false)
|
||||
let [unsavedChanges, setUnsavedChanges] = useState([])
|
||||
const [privKey, setPrivKey] = useState('')
|
||||
const [relays, setRelays] = useState([])
|
||||
const [newRelayURL, setNewRelayURL] = useState('')
|
||||
const [policies, setPermissions] = useState([])
|
||||
const [protocolHandler, setProtocolHandler] = useState(
|
||||
'https://njump.me/{raw}'
|
||||
)
|
||||
const [hidingPrivateKey, hidePrivateKey] = useState(true)
|
||||
const [showNotifications, setNotifications] = useState(false)
|
||||
const [messages, setMessages] = useState([])
|
||||
const [handleNostrLinks, setHandleNostrLinks] = useState(false)
|
||||
const [showProtocolHandlerHelp, setShowProtocolHandlerHelp] = useState(false)
|
||||
const [unsavedChanges, setUnsavedChanges] = useState([])
|
||||
|
||||
const showMessage = useCallback(msg => {
|
||||
const showMessage = useCallback((msg) => {
|
||||
messages.push(msg)
|
||||
setMessages(messages)
|
||||
setTimeout(() => setMessages([]), 3000)
|
||||
})
|
||||
|
||||
const loadPermissions = useCallback(async () => {
|
||||
const { policies = {} } = await browser.storage.local.get('policies')
|
||||
const list = []
|
||||
|
||||
Object.entries(policies).forEach(([host, accepts]) => {
|
||||
Object.entries(accepts).forEach(([accept, types]) => {
|
||||
Object.entries(types).forEach(([type, { conditions, created_at }]) => {
|
||||
list.push({
|
||||
host,
|
||||
type,
|
||||
accept,
|
||||
conditions,
|
||||
created_at
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
setPermissions(list)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
browser.storage.local
|
||||
.get(['private_key', 'relays', 'protocol_handler', 'notifications'])
|
||||
.then(results => {
|
||||
.then((results) => {
|
||||
if (results.private_key) {
|
||||
setPrivKey(nip19.nsecEncode(results.private_key))
|
||||
}
|
||||
if (results.relays) {
|
||||
let relaysList = []
|
||||
for (let url in results.relays) {
|
||||
const relaysList = []
|
||||
for (const url in results.relays) {
|
||||
relaysList.push({
|
||||
url,
|
||||
policy: results.relays[url]
|
||||
@@ -57,28 +80,7 @@ function Options() {
|
||||
|
||||
useEffect(() => {
|
||||
loadPermissions()
|
||||
}, [])
|
||||
|
||||
async function loadPermissions() {
|
||||
let {policies = {}} = await browser.storage.local.get('policies')
|
||||
let list = []
|
||||
|
||||
Object.entries(policies).forEach(([host, accepts]) => {
|
||||
Object.entries(accepts).forEach(([accept, types]) => {
|
||||
Object.entries(types).forEach(([type, {conditions, created_at}]) => {
|
||||
list.push({
|
||||
host,
|
||||
type,
|
||||
accept,
|
||||
conditions,
|
||||
created_at
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
setPermissions(list)
|
||||
}
|
||||
}, [loadPermissions])
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen flex flex-col items-center justify-center">
|
||||
@@ -178,8 +180,8 @@ function Options() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="font-semibold text-base">Preferred Relays:</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{relays.map(({url, policy}, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
{relays.map(({ url, policy }, i) => (
|
||||
<div key={url} className="flex items-center gap-4">
|
||||
<input
|
||||
value={url}
|
||||
onChange={changeRelayURL.bind(null, i)}
|
||||
@@ -205,6 +207,7 @@ function Options() {
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -240,6 +243,7 @@ function Options() {
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -258,6 +262,7 @@ function Options() {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeRelay.bind(null, i)}
|
||||
className="shrink-0 px-3 w-24 h-9 font-semibold border border-primary shadow-sm rounded-lg inline-flex items-center justify-center disabled:text-muted"
|
||||
>
|
||||
@@ -268,14 +273,15 @@ function Options() {
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newRelayURL}
|
||||
onChange={e => setNewRelayURL(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
onChange={(e) => setNewRelayURL(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') addNewRelay()
|
||||
}}
|
||||
placeholder="wss://"
|
||||
className="flex-1 h-9 bg-transparent border px-3 py-1 border-primary rounded-lg placeholder:text-muted"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!newRelayURL}
|
||||
onClick={addNewRelay}
|
||||
className="shrink-0 px-3 w-24 h-9 font-semibold border border-primary shadow-sm rounded-lg inline-flex items-center justify-center disabled:text-muted"
|
||||
@@ -317,7 +323,7 @@ function Options() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{policies.map(
|
||||
({host, type, accept, conditions, created_at}) => (
|
||||
({ host, type, accept, conditions, created_at }) => (
|
||||
<tr
|
||||
key={
|
||||
host + type + accept + JSON.stringify(conditions)
|
||||
@@ -344,6 +350,7 @@ function Options() {
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevoke}
|
||||
data-host={host}
|
||||
data-accept={accept}
|
||||
@@ -358,11 +365,11 @@ function Options() {
|
||||
)}
|
||||
{!policies.length && (
|
||||
<tr>
|
||||
{Array(5)
|
||||
.fill('N/A')
|
||||
.map((v, i) => (
|
||||
<td key={i}>{v}</td>
|
||||
))}
|
||||
<td>N/A</td>
|
||||
<td>N/A</td>
|
||||
<td>N/A</td>
|
||||
<td>N/A</td>
|
||||
<td>N/A</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
@@ -387,6 +394,7 @@ function Options() {
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -413,6 +421,7 @@ function Options() {
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -438,6 +447,7 @@ function Options() {
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -458,7 +468,10 @@ function Options() {
|
||||
onChange={handleChangeProtocolHandler}
|
||||
/>
|
||||
{!showProtocolHandlerHelp && (
|
||||
<button onClick={changeShowProtocolHandlerHelp}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={changeShowProtocolHandlerHelp}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
)}
|
||||
@@ -487,6 +500,7 @@ examples:
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!unsavedChanges.length}
|
||||
onClick={saveChanges}
|
||||
className="w-full h-10 bg-primary rounded-xl font-bold inline-flex items-center justify-center text-white disabled:cursor-not-allowed disabled:opacity-70 transform active:translate-y-1 transition-transform ease-in-out duration-75"
|
||||
@@ -498,13 +512,14 @@ examples:
|
||||
)
|
||||
|
||||
async function handleKeyChange(e) {
|
||||
let key = e.target.value.toLowerCase().trim()
|
||||
const key = e.target.value.toLowerCase().trim()
|
||||
setPrivKey(key)
|
||||
addUnsavedChanges('private_key')
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
setPrivKey(nip19.nsecEncode(generatePrivateKey()))
|
||||
const sk = generateSecretKey()
|
||||
setPrivKey(nip19.nsecEncode(utils.bytesToHex(sk)))
|
||||
addUnsavedChanges('private_key')
|
||||
}
|
||||
|
||||
@@ -517,7 +532,7 @@ examples:
|
||||
let hexOrEmptyKey = privKey
|
||||
|
||||
try {
|
||||
let {type, data} = nip19.decode(privKey)
|
||||
const { type, data } = nip19.decode(privKey)
|
||||
if (type === 'nsec') hexOrEmptyKey = data
|
||||
} catch (_) {}
|
||||
|
||||
@@ -544,7 +559,7 @@ examples:
|
||||
function changeRelayURL(i, ev) {
|
||||
setRelays([
|
||||
...relays.slice(0, i),
|
||||
{url: ev.target.value, policy: relays[i].policy},
|
||||
{ url: ev.target.value, policy: relays[i].policy },
|
||||
...relays.slice(i + 1)
|
||||
])
|
||||
addUnsavedChanges('relays')
|
||||
@@ -555,7 +570,7 @@ examples:
|
||||
...relays.slice(0, i),
|
||||
{
|
||||
url: relays[i].url,
|
||||
policy: {...relays[i].policy, [cat]: !relays[i].policy[cat]}
|
||||
policy: { ...relays[i].policy, [cat]: !relays[i].policy[cat] }
|
||||
},
|
||||
...relays.slice(i + 1)
|
||||
])
|
||||
@@ -572,7 +587,7 @@ examples:
|
||||
if (!newRelayURL.startsWith('wss://')) return
|
||||
relays.push({
|
||||
url: newRelayURL,
|
||||
policy: {read: true, write: true}
|
||||
policy: { read: true, write: true }
|
||||
})
|
||||
setRelays(relays)
|
||||
addUnsavedChanges('relays')
|
||||
@@ -580,7 +595,7 @@ examples:
|
||||
}
|
||||
|
||||
async function handleRevoke(e) {
|
||||
let {host, accept, type} = e.target.dataset
|
||||
const { host, accept, type } = e.target.dataset
|
||||
if (
|
||||
window.confirm(
|
||||
`revoke all ${
|
||||
@@ -601,14 +616,14 @@ examples:
|
||||
}
|
||||
|
||||
async function requestBrowserNotificationPermissions() {
|
||||
let granted = await browser.permissions.request({
|
||||
const granted = await browser.permissions.request({
|
||||
permissions: ['notifications']
|
||||
})
|
||||
if (!granted) setNotifications(false)
|
||||
}
|
||||
|
||||
async function saveNotifications() {
|
||||
await browser.storage.local.set({notifications: showNotifications})
|
||||
await browser.storage.local.set({ notifications: showNotifications })
|
||||
showMessage('saved notifications!')
|
||||
}
|
||||
|
||||
@@ -616,8 +631,8 @@ examples:
|
||||
await browser.storage.local.set({
|
||||
relays: Object.fromEntries(
|
||||
relays
|
||||
.filter(({url}) => url.trim() !== '')
|
||||
.map(({url, policy}) => [url.trim(), policy])
|
||||
.filter(({ url }) => url.trim() !== '')
|
||||
.map(({ url, policy }) => [url.trim(), policy])
|
||||
)
|
||||
})
|
||||
showMessage('saved relays!')
|
||||
@@ -641,19 +656,19 @@ examples:
|
||||
}
|
||||
|
||||
async function saveNostrProtocolHandlerSettings() {
|
||||
await browser.storage.local.set({protocol_handler: protocolHandler})
|
||||
await browser.storage.local.set({ protocol_handler: protocolHandler })
|
||||
showMessage('saved protocol handler!')
|
||||
}
|
||||
|
||||
function addUnsavedChanges(section) {
|
||||
if (!unsavedChanges.find(s => s === section)) {
|
||||
if (!unsavedChanges.find((s) => s === section)) {
|
||||
unsavedChanges.push(section)
|
||||
setUnsavedChanges(unsavedChanges)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
for (let section of unsavedChanges) {
|
||||
for (const section of unsavedChanges) {
|
||||
switch (section) {
|
||||
case 'private_key':
|
||||
await saveKey()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,116 +1,144 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
export const NO_PERMISSIONS_REQUIRED = {
|
||||
replaceURL: true
|
||||
}
|
||||
replaceURL: true,
|
||||
peekPublicKey: true,
|
||||
};
|
||||
|
||||
export const PERMISSION_NAMES = Object.fromEntries([
|
||||
['getPublicKey', 'read your public key'],
|
||||
['getRelays', 'read your list of preferred relays'],
|
||||
['signEvent', 'sign events using your private key'],
|
||||
['nip04.encrypt', 'encrypt messages to peers'],
|
||||
['nip04.decrypt', 'decrypt messages from peers']
|
||||
])
|
||||
["getPublicKey", "read your public key"],
|
||||
["signEvent", "sign events using your private key"],
|
||||
["nip04.encrypt", "encrypt messages to peers"],
|
||||
["nip04.decrypt", "decrypt messages from peers"],
|
||||
["nip44.encrypt", "encrypt messages to peers"],
|
||||
["nip44.decrypt", "decrypt messages from peers"],
|
||||
]);
|
||||
|
||||
function matchConditions(conditions, event) {
|
||||
if (conditions?.kinds) {
|
||||
if (event.kind in conditions.kinds) return true
|
||||
else return false
|
||||
if (event.kind in conditions.kinds) return true;
|
||||
else return false;
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getPermissionStatus(host, type, event) {
|
||||
let {policies} = await browser.storage.local.get('policies')
|
||||
const { policies } = await browser.storage.local.get("policies");
|
||||
|
||||
let answers = [true, false]
|
||||
const answers = [true, false];
|
||||
for (let i = 0; i < answers.length; i++) {
|
||||
let accept = answers[i]
|
||||
let {conditions} = policies?.[host]?.[accept]?.[type] || {}
|
||||
const accept = answers[i];
|
||||
const { conditions } = policies?.[host]?.[accept]?.[type] || {};
|
||||
|
||||
if (conditions) {
|
||||
if (type === 'signEvent') {
|
||||
if (type === "signEvent") {
|
||||
if (matchConditions(conditions, event)) {
|
||||
return accept // may be true or false
|
||||
return accept; // may be true or false
|
||||
} else {
|
||||
// if this doesn't match we just continue so it will either match for the opposite answer (reject)
|
||||
// or it will end up returning undefined at the end
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
return accept // may be true or false
|
||||
return accept; // may be true or false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function updatePermission(host, type, accept, conditions) {
|
||||
let {policies = {}} = await browser.storage.local.get('policies')
|
||||
const { policies = {} } = await browser.storage.local.get("policies");
|
||||
|
||||
// if the new conditions is "match everything", override the previous
|
||||
if (Object.keys(conditions).length === 0) {
|
||||
conditions = {}
|
||||
conditions = {};
|
||||
} else {
|
||||
// if we already had a policy for this, merge the conditions
|
||||
let existingConditions = policies[host]?.[accept]?.[type]?.conditions
|
||||
const existingConditions = policies[host]?.[accept]?.[type]?.conditions;
|
||||
if (existingConditions) {
|
||||
if (existingConditions.kinds && conditions.kinds) {
|
||||
Object.keys(existingConditions.kinds).forEach(kind => {
|
||||
conditions.kinds[kind] = true
|
||||
})
|
||||
Object.keys(existingConditions.kinds).forEach((kind) => {
|
||||
conditions.kinds[kind] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
|
||||
let other = !accept
|
||||
let reverse = policies?.[host]?.[other]?.[type]
|
||||
const other = !accept;
|
||||
const reverse = policies?.[host]?.[other]?.[type];
|
||||
if (
|
||||
reverse &&
|
||||
JSON.stringify(reverse.conditions) === JSON.stringify(conditions)
|
||||
) {
|
||||
delete policies[host][other][type]
|
||||
delete policies[host][other][type];
|
||||
}
|
||||
|
||||
// insert our new policy
|
||||
policies[host] = policies[host] || {}
|
||||
policies[host][accept] = policies[host][accept] || {}
|
||||
policies[host] = policies[host] || {};
|
||||
policies[host][accept] = policies[host][accept] || {};
|
||||
policies[host][accept][type] = {
|
||||
conditions, // filter that must match the event (in case of signEvent)
|
||||
created_at: Math.round(Date.now() / 1000)
|
||||
}
|
||||
created_at: Math.round(Date.now() / 1000),
|
||||
};
|
||||
|
||||
browser.storage.local.set({policies})
|
||||
browser.storage.local.set({ policies });
|
||||
}
|
||||
|
||||
export async function removePermissions(host, accept, type) {
|
||||
let {policies = {}} = await browser.storage.local.get('policies')
|
||||
delete policies[host]?.[accept]?.[type]
|
||||
browser.storage.local.set({policies})
|
||||
const { policies = {} } = await browser.storage.local.get("policies");
|
||||
delete policies[host]?.[accept]?.[type];
|
||||
browser.storage.local.set({ policies });
|
||||
}
|
||||
|
||||
export async function showNotification(host, answer, type, params) {
|
||||
let ok = await browser.storage.local.get('notifications')
|
||||
if (ok) {
|
||||
let action = answer ? 'allowed' : 'denied'
|
||||
const { notifications } = await browser.storage.local.get("notifications");
|
||||
if (notifications) {
|
||||
const action = answer ? "allowed" : "denied";
|
||||
browser.notifications.create(undefined, {
|
||||
type: 'basic',
|
||||
type: "basic",
|
||||
title: `${type} ${action} for ${host}`,
|
||||
message: JSON.stringify(
|
||||
params?.event
|
||||
? {
|
||||
kind: params.event.kind,
|
||||
content: params.event.content,
|
||||
tags: params.event.tags
|
||||
tags: params.event.tags,
|
||||
}
|
||||
: params,
|
||||
null,
|
||||
2
|
||||
),
|
||||
iconUrl: 'icons/48x48.png'
|
||||
})
|
||||
iconUrl: "icons/48x48.png",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPosition(width, height) {
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
try {
|
||||
const lastFocused = await browser.windows.getLastFocused();
|
||||
|
||||
if (
|
||||
lastFocused &&
|
||||
lastFocused.top !== undefined &&
|
||||
lastFocused.left !== undefined &&
|
||||
lastFocused.width !== undefined &&
|
||||
lastFocused.height !== undefined
|
||||
) {
|
||||
top = Math.round(lastFocused.top + (lastFocused.height - height) / 2);
|
||||
left = Math.round(lastFocused.left + (lastFocused.width - width) / 2);
|
||||
} else {
|
||||
console.error("Last focused window properties are undefined.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting window position:", error);
|
||||
}
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
"description": "Nostr Signer Extension",
|
||||
"version": "0.1.2",
|
||||
"homepage_url": "https://github.com/reyamir/nostr-connect",
|
||||
"manifest_version": 2,
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "{e665d138-0e5b-4b7a-ab91-7af834eda7a2}"
|
||||
}
|
||||
},
|
||||
"manifest_version": 3,
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
@@ -17,19 +12,25 @@
|
||||
},
|
||||
"options_page": "options.html",
|
||||
"background": {
|
||||
"scripts": ["background.build.js"]
|
||||
"service_worker": "background.build.js"
|
||||
},
|
||||
"browser_action": {
|
||||
"action": {
|
||||
"default_title": "Nostr Connect",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.build.js"]
|
||||
"js": ["content-script.build.js"],
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"permissions": ["storage"],
|
||||
"optional_permissions": ["notifications"],
|
||||
"web_accessible_resources": ["nostr-provider.js"]
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["nostr-provider.js"],
|
||||
"matches": ["https://*/*", "http://localhost:*/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,26 +10,40 @@ window.nostr = {
|
||||
return this._pubkey
|
||||
},
|
||||
|
||||
async peekPublicKey() {
|
||||
return this._call('peekPublicKey', {})
|
||||
},
|
||||
|
||||
async signEvent(event) {
|
||||
return this._call('signEvent', {event})
|
||||
return this._call('signEvent', { event })
|
||||
},
|
||||
|
||||
async getRelays() {
|
||||
return this._call('getRelays', {})
|
||||
return {}
|
||||
},
|
||||
|
||||
nip04: {
|
||||
async encrypt(peer, plaintext) {
|
||||
return window.nostr._call('nip04.encrypt', {peer, plaintext})
|
||||
return window.nostr._call('nip04.encrypt', { peer, plaintext })
|
||||
},
|
||||
|
||||
async decrypt(peer, ciphertext) {
|
||||
return window.nostr._call('nip04.decrypt', {peer, ciphertext})
|
||||
return window.nostr._call('nip04.decrypt', { peer, ciphertext })
|
||||
}
|
||||
},
|
||||
|
||||
nip44: {
|
||||
async encrypt(peer, plaintext) {
|
||||
return window.nostr._call('nip44.encrypt', { peer, plaintext })
|
||||
},
|
||||
|
||||
async decrypt(peer, ciphertext) {
|
||||
return window.nostr._call('nip44.decrypt', { peer, ciphertext })
|
||||
}
|
||||
},
|
||||
|
||||
_call(type, params) {
|
||||
let id = Math.random().toString().slice(-4)
|
||||
const id = Math.random().toString().slice(-4)
|
||||
console.log(
|
||||
'%c[nostrconnect:%c' +
|
||||
id +
|
||||
@@ -46,7 +60,7 @@ window.nostr = {
|
||||
'font-weight:bold;color:#90b12d;font-family:monospace'
|
||||
)
|
||||
return new Promise((resolve, reject) => {
|
||||
this._requests[id] = {resolve, reject}
|
||||
this._requests[id] = { resolve, reject }
|
||||
window.postMessage(
|
||||
{
|
||||
id,
|
||||
@@ -60,7 +74,7 @@ window.nostr = {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', message => {
|
||||
window.addEventListener('message', (message) => {
|
||||
if (
|
||||
!message.data ||
|
||||
message.data.response === null ||
|
||||
@@ -71,8 +85,8 @@ window.addEventListener('message', message => {
|
||||
return
|
||||
|
||||
if (message.data.response.error) {
|
||||
let error = new Error(
|
||||
`${EXTENSION}: ` + message.data.response.error.message
|
||||
const error = new Error(
|
||||
`${EXTENSION}: ${message.data.response.error.message}`
|
||||
)
|
||||
error.stack = message.data.response.error.stack
|
||||
window.nostr._requests[message.data.id].reject(error)
|
||||
@@ -104,7 +118,9 @@ async function replaceNostrSchemeLink(e) {
|
||||
if (e.target.tagName !== 'A' || !e.target.href.startsWith('nostr:')) return
|
||||
if (replacing === false) return
|
||||
|
||||
let response = await window.nostr._call('replaceURL', {url: e.target.href})
|
||||
const response = await window.nostr._call('replaceURL', {
|
||||
url: e.target.href
|
||||
})
|
||||
if (response === false) {
|
||||
replacing = false
|
||||
return
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,15 +1,15 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import {render} from 'react-dom'
|
||||
import {getPublicKey, nip19} from 'nostr-tools'
|
||||
import React, {useState, useMemo, useEffect} from 'react'
|
||||
import { render } from 'react-dom'
|
||||
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import QRCode from 'react-qr-code'
|
||||
import {SettingsIcon} from './icons'
|
||||
import {minidenticon} from 'minidenticons'
|
||||
import { SettingsIcon } from './icons'
|
||||
import { minidenticon } from 'minidenticons'
|
||||
import * as Tabs from '@radix-ui/react-tabs'
|
||||
|
||||
function Popup() {
|
||||
let [keys, setKeys] = useState(null)
|
||||
let avatarURI = useMemo(
|
||||
const [keys, setKeys] = useState(null)
|
||||
const avatarURI = useMemo(
|
||||
() =>
|
||||
keys
|
||||
? 'data:image/svg+xml;utf8,' +
|
||||
@@ -25,27 +25,27 @@ function Popup() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
browser.storage.local.get(['private_key', 'relays']).then(results => {
|
||||
browser.storage.local.get(['private_key', 'relays']).then((results) => {
|
||||
if (results.private_key) {
|
||||
let hexKey = getPublicKey(results.private_key)
|
||||
let npubKey = nip19.npubEncode(hexKey)
|
||||
const hexKey = getPublicKey(results.private_key)
|
||||
const npubKey = nip19.npubEncode(hexKey)
|
||||
|
||||
setKeys({npub: npubKey, hex: hexKey})
|
||||
setKeys({ npub: npubKey, hex: hexKey })
|
||||
|
||||
if (results.relays) {
|
||||
let relaysList = []
|
||||
for (let url in results.relays) {
|
||||
const relaysList = []
|
||||
for (const url in results.relays) {
|
||||
if (results.relays[url].write) {
|
||||
relaysList.push(url)
|
||||
if (relaysList.length >= 3) break
|
||||
}
|
||||
}
|
||||
if (relaysList.length) {
|
||||
let nprofileKey = nip19.nprofileEncode({
|
||||
const nprofileKey = nip19.nprofileEncode({
|
||||
pubkey: hexKey,
|
||||
relays: relaysList
|
||||
})
|
||||
setKeys(prev => ({...prev, nprofile: nprofileKey}))
|
||||
setKeys((prev) => ({ ...prev, nprofile: nprofileKey }))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -71,6 +71,7 @@ function Popup() {
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -95,6 +96,7 @@ function Popup() {
|
||||
<img
|
||||
src={avatarURI}
|
||||
className="w-9 h-9 rounded-full bg-muted"
|
||||
alt="Avatar"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-muted" />
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import browser from 'webextension-polyfill'
|
||||
import {render} from 'react-dom'
|
||||
import React, {useState} from 'react'
|
||||
import { render } from 'react-dom'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {PERMISSION_NAMES} from './common'
|
||||
import {LogoIcon} from './icons'
|
||||
import { PERMISSION_NAMES } from './common'
|
||||
import { LogoIcon } from './icons'
|
||||
import * as Checkbox from '@radix-ui/react-checkbox'
|
||||
|
||||
function Prompt() {
|
||||
const [isRemember, setIsRemember] = useState(false)
|
||||
|
||||
let qs = new URLSearchParams(location.search)
|
||||
let id = qs.get('id')
|
||||
let host = qs.get('host')
|
||||
let type = qs.get('type')
|
||||
const qs = new URLSearchParams(location.search)
|
||||
const id = qs.get('id')
|
||||
const host = qs.get('host')
|
||||
const type = qs.get('type')
|
||||
let params, event
|
||||
|
||||
try {
|
||||
params = JSON.parse(qs.get('params'))
|
||||
if (Object.keys(params).length === 0) params = null
|
||||
else if (params.event) event = params.event
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
params = null
|
||||
}
|
||||
|
||||
function authorizeHandler(accept) {
|
||||
const conditions = isRemember ? {} : null
|
||||
return function (ev) {
|
||||
return (ev) => {
|
||||
ev.preventDefault()
|
||||
browser.runtime.sendMessage({
|
||||
prompt: true,
|
||||
@@ -73,6 +73,7 @@ function Prompt() {
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -88,12 +89,14 @@ function Prompt() {
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={authorizeHandler(false)}
|
||||
className="flex-1 h-10 rounded-lg shadow-sm border border-primary inline-flex items-center justify-center font-semibold"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={authorizeHandler(true)}
|
||||
className="flex-1 h-10 rounded-lg shadow-sm border border-secondary bg-primary text-white inline-flex items-center justify-center font-semibold"
|
||||
>
|
||||
|
||||
86
extension/test-utils.js
Normal file
86
extension/test-utils.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
const storage = {}
|
||||
let notificationId = 0
|
||||
let notifications = []
|
||||
|
||||
export const mockBrowser = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn((keys) => {
|
||||
if (typeof keys === 'string') {
|
||||
return Promise.resolve({ [keys]: storage[keys] })
|
||||
}
|
||||
if (Array.isArray(keys)) {
|
||||
const result = {}
|
||||
keys.forEach((key) => {
|
||||
result[key] = storage[key]
|
||||
})
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
if (keys && typeof keys === 'object') {
|
||||
const result = {}
|
||||
Object.keys(keys).forEach((key) => {
|
||||
result[key] = storage[key] !== undefined ? storage[key] : keys[key]
|
||||
})
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
set: vi.fn((obj) => {
|
||||
Object.assign(storage, obj)
|
||||
return Promise.resolve()
|
||||
}),
|
||||
remove: vi.fn((keys) => {
|
||||
if (Array.isArray(keys)) {
|
||||
keys.forEach((key) => delete storage[key])
|
||||
} else {
|
||||
delete storage[keys]
|
||||
}
|
||||
return Promise.resolve()
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(storage).forEach((key) => delete storage[key])
|
||||
return Promise.resolve()
|
||||
}),
|
||||
_reset: () => {
|
||||
Object.keys(storage).forEach((key) => delete storage[key])
|
||||
}
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
create: vi.fn((id, options) => {
|
||||
notifications.push({ id, options })
|
||||
return `notification-${++notificationId}`
|
||||
}),
|
||||
_notifications: notifications,
|
||||
_reset: () => {
|
||||
notifications.length = 0
|
||||
notificationId = 0
|
||||
}
|
||||
},
|
||||
windows: {
|
||||
getLastFocused: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
top: 100,
|
||||
left: 100,
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
),
|
||||
create: vi.fn(() => Promise.resolve({ id: 123 })),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
get: vi.fn(() => Promise.resolve({ id: 123, top: 100, left: 100 }))
|
||||
},
|
||||
tabs: {
|
||||
create: vi.fn(() => Promise.resolve({ id: 456 })),
|
||||
remove: vi.fn(() => Promise.resolve())
|
||||
},
|
||||
runtime: {
|
||||
getURL: vi.fn((path) => `chrome-extension://abc123/${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
global.browser = mockBrowser
|
||||
|
||||
export { storage }
|
||||
38
extension/utils.js
Normal file
38
extension/utils.js
Normal file
@@ -0,0 +1,38 @@
|
||||
export class LRUCache {
|
||||
constructor(maxSize) {
|
||||
this.maxSize = maxSize;
|
||||
this.map = new Map();
|
||||
this.keys = [];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
has(k) {
|
||||
return this.map.has(k);
|
||||
}
|
||||
|
||||
get(k) {
|
||||
const v = this.map.get(k);
|
||||
|
||||
if (v !== undefined) {
|
||||
this.keys.push(k);
|
||||
|
||||
if (this.keys.length > this.maxSize * 2) {
|
||||
this.keys.splice(-this.maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
set(k, v) {
|
||||
this.map.set(k, v);
|
||||
this.keys.push(k);
|
||||
|
||||
if (this.map.size > this.maxSize) {
|
||||
this.map.delete(this.keys.shift());
|
||||
}
|
||||
}
|
||||
}
|
||||
78
extension/utils.test.js
Normal file
78
extension/utils.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { LRUCache } from './utils'
|
||||
|
||||
describe('LRUCache', () => {
|
||||
let cache
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new LRUCache(3)
|
||||
})
|
||||
|
||||
describe('basic operations', () => {
|
||||
it('should store and retrieve values', () => {
|
||||
cache.set('a', 1)
|
||||
expect(cache.get('a')).toBe(1)
|
||||
})
|
||||
|
||||
it('should return undefined for missing keys', () => {
|
||||
expect(cache.get('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should check if key exists', () => {
|
||||
cache.set('a', 1)
|
||||
expect(cache.has('a')).toBe(true)
|
||||
expect(cache.has('b')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('eviction', () => {
|
||||
it('should evict least recently used when full', () => {
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
cache.set('c', 3)
|
||||
cache.set('d', 4) // Should evict 'a'
|
||||
|
||||
expect(cache.get('a')).toBeUndefined()
|
||||
expect(cache.get('b')).toBe(2)
|
||||
expect(cache.get('c')).toBe(3)
|
||||
expect(cache.get('d')).toBe(4)
|
||||
})
|
||||
|
||||
it('should update existing key and move to most recent', () => {
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
cache.set('c', 3)
|
||||
cache.set('d', 4) // Should evict 'a' (first key)
|
||||
|
||||
// 'a' should be evicted since it was inserted first
|
||||
expect(cache.get('a')).toBeUndefined()
|
||||
expect(cache.get('b')).toBe(2)
|
||||
expect(cache.get('c')).toBe(3)
|
||||
expect(cache.get('d')).toBe(4)
|
||||
})
|
||||
|
||||
it('should handle accessing keys updates their position', () => {
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
cache.set('c', 3)
|
||||
cache.get('a') // Access 'a', pushing it to keys again
|
||||
cache.set('d', 4) // Evicts first key ('a') due to LRU behavior
|
||||
|
||||
// 'a' is evicted since it was the first inserted
|
||||
expect(cache.get('b')).toBe(2)
|
||||
expect(cache.get('c')).toBe(3)
|
||||
expect(cache.get('a')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('should remove all entries', () => {
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
cache.clear()
|
||||
|
||||
expect(cache.get('a')).toBeUndefined()
|
||||
expect(cache.get('b')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
68
package.json
68
package.json
@@ -1,33 +1,39 @@
|
||||
{
|
||||
"license": "WTFPL",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"async-mutex": "^0.3.2",
|
||||
"esbuild": "^0.14.54",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-plugin-babel": "^5.3.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"events": "^3.3.0",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-native-svg": "^13.14.0",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"use-boolean-state": "^1.0.2",
|
||||
"use-debounce": "^7.0.1",
|
||||
"webextension-polyfill": "^0.8.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "./build.js; pnpm exec tailwindcss -i ./extension/style.css -o ./extension/build/style.css --watch",
|
||||
"build": "pnpm exec tailwindcss -i ./extension/style.css -o ./extension/output/style.css; ./build.js prod",
|
||||
"package:chrome": "pnpm exec tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_chrome.zip",
|
||||
"package:firefox": "pnpm exec tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod firefox; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_firefox.xpi"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild-plugin-copy": "^2.1.1",
|
||||
"tailwindcss": "^3.3.5"
|
||||
}
|
||||
"license": "WTFPL",
|
||||
"dependencies": {
|
||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"async-mutex": "^0.3.2",
|
||||
"esbuild": "^0.14.54",
|
||||
"events": "^3.3.0",
|
||||
"minidenticons": "^4.2.1",
|
||||
"nostr-tools": "^2.8.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-native-svg": "^13.14.1",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"use-boolean-state": "^1.0.2",
|
||||
"use-debounce": "^7.0.1",
|
||||
"webextension-polyfill": "^0.8.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "./build.js; bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css --watch",
|
||||
"build": "bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css; ./build.js prod",
|
||||
"package:chrome": "bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_chrome.zip",
|
||||
"package:firefox": "bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod firefox; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_firefox.xpi",
|
||||
"lint": "biome lint ./extension/background.js ./extension/common.js ./extension/nostr-provider.js ./extension/content-script.js ./extension/popup.jsx ./extension/prompt.jsx ./extension/options.jsx ./extension/icons.jsx ./extension/utils.js",
|
||||
"format": "biome format --write ./extension/background.js ./extension/common.js ./extension/nostr-provider.js ./extension/content-script.js ./extension/popup.jsx ./extension/prompt.jsx ./extension/options.jsx ./extension/icons.jsx ./extension/utils.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"esbuild-plugin-copy": "^2.1.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
10240
pnpm-lock.yaml
generated
10240
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: false
|
||||
14
vitest.config.js
Normal file
14
vitest.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['extension/**/*.test.{js,jsx}'],
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['extension/**/*.js'],
|
||||
exclude: ['extension/output/**', 'extension/**/*.test.js']
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user