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 {
|
import {
|
||||||
validateEvent,
|
validateEvent,
|
||||||
getSignature,
|
finalizeEvent,
|
||||||
getEventHash,
|
getEventHash,
|
||||||
getPublicKey,
|
getPublicKey,
|
||||||
nip19
|
nip19,
|
||||||
} from 'nostr-tools'
|
utils,
|
||||||
import {nip04} from 'nostr-tools'
|
} from "nostr-tools";
|
||||||
import {Mutex} from 'async-mutex'
|
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 {
|
import {
|
||||||
NO_PERMISSIONS_REQUIRED,
|
NO_PERMISSIONS_REQUIRED,
|
||||||
getPermissionStatus,
|
getPermissionStatus,
|
||||||
updatePermission,
|
updatePermission,
|
||||||
showNotification
|
showNotification,
|
||||||
} from './common'
|
getPosition,
|
||||||
|
} from "./common";
|
||||||
|
|
||||||
const {encrypt, decrypt} = nip04
|
const { encrypt, decrypt } = nip04;
|
||||||
|
|
||||||
let openPrompt = null
|
let openPrompt = null;
|
||||||
let promptMutex = new Mutex()
|
const promptMutex = new Mutex();
|
||||||
let releasePromptMutex = () => {}
|
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) => {
|
browser.runtime.onInstalled.addListener((_, __, reason) => {
|
||||||
if (reason === 'install') browser.runtime.openOptionsPage()
|
if (reason === "install") browser.runtime.openOptionsPage();
|
||||||
})
|
});
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener(async (req, sender) => {
|
browser.runtime.onMessage.addListener(async (req, sender) => {
|
||||||
let {prompt} = req
|
const { prompt } = req;
|
||||||
|
|
||||||
if (prompt) {
|
if (prompt) {
|
||||||
handlePromptMessage(req, sender)
|
handlePromptMessage(req, sender);
|
||||||
} else {
|
} else {
|
||||||
return handleContentScriptMessage(req)
|
return handleContentScriptMessage(req);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
browser.runtime.onMessageExternal.addListener(
|
browser.runtime.onMessageExternal.addListener(
|
||||||
async ({type, params}, sender) => {
|
async ({ type, params }, sender) => {
|
||||||
let extensionId = new URL(sender.url).host
|
const extensionId = new URL(sender.url).host;
|
||||||
return handleContentScriptMessage({type, params, host: extensionId})
|
return handleContentScriptMessage({ type, params, host: extensionId });
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
browser.windows.onRemoved.addListener(windowId => {
|
browser.windows.onRemoved.addListener((_windowId) => {
|
||||||
if (openPrompt) {
|
if (openPrompt) {
|
||||||
// calling this with a simple "no" response will not store anything, so it's fine
|
// calling this with a simple "no" response will not store anything, so it's fine
|
||||||
// it will just return a failure
|
// 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]) {
|
if (NO_PERMISSIONS_REQUIRED[type]) {
|
||||||
// authorized, and we won't do anything with private key here, so do a separate handler
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'replaceURL': {
|
case "peekPublicKey": {
|
||||||
let {protocol_handler: ph} = await browser.storage.local.get([
|
const allowed = await getPermissionStatus(host, "getPublicKey");
|
||||||
'protocol_handler'
|
if (allowed === true) return performOperation("getPublicKey", params);
|
||||||
])
|
return "";
|
||||||
if (!ph) return false
|
}
|
||||||
|
case "replaceURL": {
|
||||||
|
const { protocol_handler: ph } = await browser.storage.local.get([
|
||||||
|
"protocol_handler",
|
||||||
|
]);
|
||||||
|
if (!ph) return false;
|
||||||
|
|
||||||
let {url} = params
|
const { url } = params;
|
||||||
let raw = url.split('nostr:')[1]
|
const raw = url.split("nostr:")[1];
|
||||||
let {type, data} = nip19.decode(raw)
|
const { type, data } = nip19.decode(raw);
|
||||||
let replacements = {
|
const replacements = {
|
||||||
raw,
|
raw,
|
||||||
hrp: type,
|
hrp: type,
|
||||||
hex:
|
hex:
|
||||||
type === 'npub' || type === 'note'
|
type === "npub" || type === "note"
|
||||||
? data
|
? data
|
||||||
: type === 'nprofile'
|
: type === "nprofile"
|
||||||
? data.pubkey
|
? data.pubkey
|
||||||
: type === 'nevent'
|
: type === "nevent"
|
||||||
? data.id
|
? data.id
|
||||||
: null,
|
: null,
|
||||||
p_or_e: {npub: 'p', note: 'e', nprofile: 'p', nevent: 'e'}[type],
|
p_or_e: { npub: "p", note: "e", nprofile: "p", nevent: "e" }[type],
|
||||||
u_or_n: {npub: 'u', note: 'n', nprofile: 'u', nevent: 'n'}[type],
|
u_or_n: { npub: "u", note: "n", nprofile: "u", nevent: "n" }[type],
|
||||||
relay0: type === 'nprofile' ? data.relays[0] : null,
|
relay0: type === "nprofile" ? data.relays[0] : null,
|
||||||
relay1: type === 'nprofile' ? data.relays[1] : null,
|
relay1: type === "nprofile" ? data.relays[1] : null,
|
||||||
relay2: type === 'nprofile' ? data.relays[2] : null
|
relay2: type === "nprofile" ? data.relays[2] : null,
|
||||||
}
|
};
|
||||||
let result = ph
|
let result = ph;
|
||||||
Object.entries(replacements).forEach(([pattern, value]) => {
|
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 {
|
} else {
|
||||||
// acquire mutex here before reading policies
|
// acquire mutex here before reading policies
|
||||||
releasePromptMutex = await promptMutex.acquire()
|
releasePromptMutex = await promptMutex.acquire();
|
||||||
|
|
||||||
let allowed = await getPermissionStatus(
|
const allowed = await getPermissionStatus(
|
||||||
host,
|
host,
|
||||||
type,
|
type,
|
||||||
type === 'signEvent' ? params.event : undefined
|
type === "signEvent" ? params.event : undefined
|
||||||
)
|
);
|
||||||
|
|
||||||
if (allowed === true) {
|
if (allowed === true) {
|
||||||
// authorized, proceed
|
// authorized, proceed
|
||||||
releasePromptMutex()
|
releasePromptMutex();
|
||||||
showNotification(host, allowed, type, params)
|
showNotification(host, allowed, type, params);
|
||||||
} else if (allowed === false) {
|
} else if (allowed === false) {
|
||||||
// denied, just refuse immediately
|
// denied, just refuse immediately
|
||||||
releasePromptMutex()
|
releasePromptMutex();
|
||||||
showNotification(host, allowed, type, params)
|
showNotification(host, allowed, type, params);
|
||||||
return {
|
return {
|
||||||
error: 'denied'
|
error: "denied",
|
||||||
}
|
};
|
||||||
} else {
|
} else {
|
||||||
// ask for authorization
|
// ask for authorization
|
||||||
try {
|
try {
|
||||||
let id = Math.random().toString().slice(4)
|
const id = Math.random().toString().slice(4);
|
||||||
let qs = new URLSearchParams({
|
const qs = new URLSearchParams({
|
||||||
host,
|
host,
|
||||||
id,
|
id,
|
||||||
params: JSON.stringify(params),
|
params: JSON.stringify(params),
|
||||||
type
|
type,
|
||||||
})
|
});
|
||||||
|
|
||||||
// prompt will be resolved with true or false
|
// prompt will be resolved with true or false
|
||||||
let accept = await new Promise((resolve, reject) => {
|
const accept = await new Promise((resolve, reject) => {
|
||||||
openPrompt = {resolve, reject}
|
openPrompt = { resolve, reject };
|
||||||
const url = `${browser.runtime.getURL(
|
const url = `${browser.runtime.getURL(
|
||||||
'prompt.html'
|
"prompt.html"
|
||||||
)}?${qs.toString()}`
|
)}?${qs.toString()}`;
|
||||||
|
|
||||||
|
// center prompt
|
||||||
|
const { top, left } = getPosition(width, height);
|
||||||
|
|
||||||
if (browser.windows) {
|
if (browser.windows) {
|
||||||
browser.windows.create({
|
browser.windows.create({
|
||||||
url,
|
url,
|
||||||
type: 'popup',
|
type: "popup",
|
||||||
width: 600,
|
width: width,
|
||||||
height: 600
|
height: height,
|
||||||
})
|
top: top,
|
||||||
|
left: left,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
browser.tabs.create({
|
browser.tabs.create({
|
||||||
url,
|
url,
|
||||||
active: true
|
active: true,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// denied, stop here
|
// denied, stop here
|
||||||
if (!accept) return {error: 'denied'}
|
if (!accept) return { error: { message: "denied" } };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// errored, stop here
|
// errored, stop here
|
||||||
releasePromptMutex()
|
releasePromptMutex();
|
||||||
return {
|
return {
|
||||||
error: `error: ${err}`
|
error: { message: err.message, stack: err.stack },
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we're here this means it was accepted
|
// if we're here this means it was accepted
|
||||||
let results = await browser.storage.local.get('private_key')
|
const results = await browser.storage.local.get("private_key");
|
||||||
if (!results || !results.private_key) {
|
if (!results?.private_key) {
|
||||||
return {error: 'no private key found'}
|
return { error: "no private key found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
let sk = results.private_key
|
const sk = results.private_key;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'getPublicKey': {
|
case "getPublicKey": {
|
||||||
return getPublicKey(sk)
|
return getPublicKey(hexToBytes(sk));
|
||||||
}
|
}
|
||||||
case 'getRelays': {
|
case "getRelays": {
|
||||||
let results = await browser.storage.local.get('relays')
|
const results = await browser.storage.local.get("relays");
|
||||||
return results.relays || {}
|
return results.relays || {};
|
||||||
}
|
}
|
||||||
case 'signEvent': {
|
case "signEvent": {
|
||||||
let {event} = params
|
const { event } = params;
|
||||||
|
|
||||||
if (!event.pubkey) event.pubkey = getPublicKey(sk)
|
if (!event.pubkey) event.pubkey = getPublicKey(hexToBytes(sk));
|
||||||
if (!event.id) event.id = getEventHash(event)
|
if (!event.id) event.id = getEventHash(event);
|
||||||
if (!validateEvent(event)) return {error: {message: 'invalid event'}}
|
if (!validateEvent(event))
|
||||||
|
return { error: { message: "invalid event" } };
|
||||||
|
|
||||||
event.sig = await getSignature(event, sk)
|
const signedEvent = finalizeEvent(event, hexToBytes(sk));
|
||||||
return event
|
return signedEvent;
|
||||||
}
|
}
|
||||||
case 'nip04.encrypt': {
|
case "nip04.encrypt": {
|
||||||
let {peer, plaintext} = params
|
const { peer, plaintext } = params;
|
||||||
return encrypt(sk, peer, plaintext)
|
return encrypt(sk, peer, plaintext);
|
||||||
}
|
}
|
||||||
case 'nip04.decrypt': {
|
case "nip04.decrypt": {
|
||||||
let {peer, ciphertext} = params
|
const { peer, ciphertext } = params;
|
||||||
return decrypt(sk, peer, ciphertext)
|
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) {
|
} 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
|
// return response
|
||||||
openPrompt?.resolve?.(accept)
|
openPrompt?.resolve?.(accept);
|
||||||
|
|
||||||
// update policies
|
// update policies
|
||||||
if (conditions) {
|
if (conditions) {
|
||||||
await updatePermission(host, type, accept, conditions)
|
await updatePermission(host, type, accept, conditions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanup this
|
// cleanup this
|
||||||
openPrompt = null
|
openPrompt = null;
|
||||||
|
|
||||||
// release mutex here after updating policies
|
// release mutex here after updating policies
|
||||||
releasePromptMutex()
|
releasePromptMutex();
|
||||||
|
|
||||||
// close prompt
|
// close prompt
|
||||||
if (sender) {
|
if (sender) {
|
||||||
if (browser.windows) {
|
if (browser.windows) {
|
||||||
browser.windows.remove(sender.tab.windowId)
|
browser.windows.remove(sender.tab.windowId);
|
||||||
} else {
|
} else {
|
||||||
// Android Firefox
|
// 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 = {
|
export const NO_PERMISSIONS_REQUIRED = {
|
||||||
replaceURL: true
|
replaceURL: true,
|
||||||
}
|
peekPublicKey: true,
|
||||||
|
};
|
||||||
|
|
||||||
export const PERMISSION_NAMES = Object.fromEntries([
|
export const PERMISSION_NAMES = Object.fromEntries([
|
||||||
['getPublicKey', 'read your public key'],
|
["getPublicKey", "read your public key"],
|
||||||
['getRelays', 'read your list of preferred relays'],
|
["signEvent", "sign events using your private key"],
|
||||||
['signEvent', 'sign events using your private key'],
|
["nip04.encrypt", "encrypt messages to peers"],
|
||||||
['nip04.encrypt', 'encrypt messages to peers'],
|
["nip04.decrypt", "decrypt messages from peers"],
|
||||||
['nip04.decrypt', 'decrypt messages from peers']
|
["nip44.encrypt", "encrypt messages to peers"],
|
||||||
])
|
["nip44.decrypt", "decrypt messages from peers"],
|
||||||
|
]);
|
||||||
|
|
||||||
function matchConditions(conditions, event) {
|
function matchConditions(conditions, event) {
|
||||||
if (conditions?.kinds) {
|
if (conditions?.kinds) {
|
||||||
if (event.kind in conditions.kinds) return true
|
if (event.kind in conditions.kinds) return true;
|
||||||
else return false
|
else return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPermissionStatus(host, type, event) {
|
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++) {
|
for (let i = 0; i < answers.length; i++) {
|
||||||
let accept = answers[i]
|
const accept = answers[i];
|
||||||
let {conditions} = policies?.[host]?.[accept]?.[type] || {}
|
const { conditions } = policies?.[host]?.[accept]?.[type] || {};
|
||||||
|
|
||||||
if (conditions) {
|
if (conditions) {
|
||||||
if (type === 'signEvent') {
|
if (type === "signEvent") {
|
||||||
if (matchConditions(conditions, event)) {
|
if (matchConditions(conditions, event)) {
|
||||||
return accept // may be true or false
|
return accept; // may be true or false
|
||||||
} else {
|
} 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 {
|
} 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) {
|
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 the new conditions is "match everything", override the previous
|
||||||
if (Object.keys(conditions).length === 0) {
|
if (Object.keys(conditions).length === 0) {
|
||||||
conditions = {}
|
conditions = {};
|
||||||
} else {
|
} else {
|
||||||
// if we already had a policy for this, merge the conditions
|
// 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) {
|
||||||
if (existingConditions.kinds && conditions.kinds) {
|
if (existingConditions.kinds && conditions.kinds) {
|
||||||
Object.keys(existingConditions.kinds).forEach(kind => {
|
Object.keys(existingConditions.kinds).forEach((kind) => {
|
||||||
conditions.kinds[kind] = true
|
conditions.kinds[kind] = true;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
|
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
|
||||||
let other = !accept
|
const other = !accept;
|
||||||
let reverse = policies?.[host]?.[other]?.[type]
|
const reverse = policies?.[host]?.[other]?.[type];
|
||||||
if (
|
if (
|
||||||
reverse &&
|
reverse &&
|
||||||
JSON.stringify(reverse.conditions) === JSON.stringify(conditions)
|
JSON.stringify(reverse.conditions) === JSON.stringify(conditions)
|
||||||
) {
|
) {
|
||||||
delete policies[host][other][type]
|
delete policies[host][other][type];
|
||||||
}
|
}
|
||||||
|
|
||||||
// insert our new policy
|
// insert our new policy
|
||||||
policies[host] = policies[host] || {}
|
policies[host] = policies[host] || {};
|
||||||
policies[host][accept] = policies[host][accept] || {}
|
policies[host][accept] = policies[host][accept] || {};
|
||||||
policies[host][accept][type] = {
|
policies[host][accept][type] = {
|
||||||
conditions, // filter that must match the event (in case of signEvent)
|
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) {
|
export async function removePermissions(host, accept, type) {
|
||||||
let {policies = {}} = await browser.storage.local.get('policies')
|
const { policies = {} } = await browser.storage.local.get("policies");
|
||||||
delete policies[host]?.[accept]?.[type]
|
delete policies[host]?.[accept]?.[type];
|
||||||
browser.storage.local.set({policies})
|
browser.storage.local.set({ policies });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function showNotification(host, answer, type, params) {
|
export async function showNotification(host, answer, type, params) {
|
||||||
let ok = await browser.storage.local.get('notifications')
|
const { notifications } = await browser.storage.local.get("notifications");
|
||||||
if (ok) {
|
if (notifications) {
|
||||||
let action = answer ? 'allowed' : 'denied'
|
const action = answer ? "allowed" : "denied";
|
||||||
browser.notifications.create(undefined, {
|
browser.notifications.create(undefined, {
|
||||||
type: 'basic',
|
type: "basic",
|
||||||
title: `${type} ${action} for ${host}`,
|
title: `${type} ${action} for ${host}`,
|
||||||
message: JSON.stringify(
|
message: JSON.stringify(
|
||||||
params?.event
|
params?.event
|
||||||
? {
|
? {
|
||||||
kind: params.event.kind,
|
kind: params.event.kind,
|
||||||
content: params.event.content,
|
content: params.event.content,
|
||||||
tags: params.event.tags
|
tags: params.event.tags,
|
||||||
}
|
}
|
||||||
: params,
|
: params,
|
||||||
null,
|
null,
|
||||||
2
|
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
|
// inject the script that will provide window.nostr
|
||||||
let script = document.createElement('script')
|
const script = document.createElement("script");
|
||||||
script.setAttribute('async', 'false')
|
script.setAttribute("async", "false");
|
||||||
script.setAttribute('type', 'text/javascript')
|
script.setAttribute("type", "text/javascript");
|
||||||
script.setAttribute('src', browser.runtime.getURL('nostr-provider.js'))
|
script.setAttribute("src", browser.runtime.getURL("nostr-provider.js"));
|
||||||
document.head.appendChild(script)
|
document.head.appendChild(script);
|
||||||
|
|
||||||
// listen for messages from that script
|
// listen for messages from that script
|
||||||
window.addEventListener('message', async message => {
|
window.addEventListener("message", async (message) => {
|
||||||
if (message.source !== window) return
|
if (message.source !== window) return;
|
||||||
if (!message.data) return
|
if (!message.data) return;
|
||||||
if (!message.data.params) return
|
if (!message.data.params) return;
|
||||||
if (message.data.ext !== EXTENSION) return
|
if (message.data.ext !== EXTENSION) return;
|
||||||
|
|
||||||
// pass on to background
|
// pass on to background
|
||||||
var response
|
var response;
|
||||||
try {
|
try {
|
||||||
response = await browser.runtime.sendMessage({
|
response = await browser.runtime.sendMessage({
|
||||||
type: message.data.type,
|
type: message.data.type,
|
||||||
params: message.data.params,
|
params: message.data.params,
|
||||||
host: location.host
|
host: location.host,
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
response = {error}
|
response = { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
// return response
|
// return response
|
||||||
window.postMessage(
|
window.postMessage(
|
||||||
{id: message.data.id, ext: EXTENSION, response},
|
{ id: message.data.id, ext: EXTENSION, response },
|
||||||
message.origin
|
message.origin
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
export function LogoIcon() {
|
export function LogoIcon() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -8,6 +6,7 @@ export function LogoIcon() {
|
|||||||
height="56"
|
height="56"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 56 56"
|
viewBox="0 0 56 56"
|
||||||
|
aria-label="Nostr Connect logo"
|
||||||
>
|
>
|
||||||
<rect width="56" height="56" fill="#EEECFD" rx="16"></rect>
|
<rect width="56" height="56" fill="#EEECFD" rx="16"></rect>
|
||||||
<rect
|
<rect
|
||||||
@@ -70,6 +69,7 @@ export function SettingsIcon(props) {
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
aria-label="Settings"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -10,26 +10,40 @@ window.nostr = {
|
|||||||
return this._pubkey
|
return this._pubkey
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async peekPublicKey() {
|
||||||
|
return this._call('peekPublicKey', {})
|
||||||
|
},
|
||||||
|
|
||||||
async signEvent(event) {
|
async signEvent(event) {
|
||||||
return this._call('signEvent', {event})
|
return this._call('signEvent', { event })
|
||||||
},
|
},
|
||||||
|
|
||||||
async getRelays() {
|
async getRelays() {
|
||||||
return this._call('getRelays', {})
|
return {}
|
||||||
},
|
},
|
||||||
|
|
||||||
nip04: {
|
nip04: {
|
||||||
async encrypt(peer, plaintext) {
|
async encrypt(peer, plaintext) {
|
||||||
return window.nostr._call('nip04.encrypt', {peer, plaintext})
|
return window.nostr._call('nip04.encrypt', { peer, plaintext })
|
||||||
},
|
},
|
||||||
|
|
||||||
async decrypt(peer, ciphertext) {
|
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) {
|
_call(type, params) {
|
||||||
let id = Math.random().toString().slice(-4)
|
const id = Math.random().toString().slice(-4)
|
||||||
console.log(
|
console.log(
|
||||||
'%c[nostrconnect:%c' +
|
'%c[nostrconnect:%c' +
|
||||||
id +
|
id +
|
||||||
@@ -46,7 +60,7 @@ window.nostr = {
|
|||||||
'font-weight:bold;color:#90b12d;font-family:monospace'
|
'font-weight:bold;color:#90b12d;font-family:monospace'
|
||||||
)
|
)
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this._requests[id] = {resolve, reject}
|
this._requests[id] = { resolve, reject }
|
||||||
window.postMessage(
|
window.postMessage(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -60,7 +74,7 @@ window.nostr = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('message', message => {
|
window.addEventListener('message', (message) => {
|
||||||
if (
|
if (
|
||||||
!message.data ||
|
!message.data ||
|
||||||
message.data.response === null ||
|
message.data.response === null ||
|
||||||
@@ -71,8 +85,8 @@ window.addEventListener('message', message => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
if (message.data.response.error) {
|
if (message.data.response.error) {
|
||||||
let error = new Error(
|
const error = new Error(
|
||||||
`${EXTENSION}: ` + message.data.response.error.message
|
`${EXTENSION}: ${message.data.response.error.message}`
|
||||||
)
|
)
|
||||||
error.stack = message.data.response.error.stack
|
error.stack = message.data.response.error.stack
|
||||||
window.nostr._requests[message.data.id].reject(error)
|
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 (e.target.tagName !== 'A' || !e.target.href.startsWith('nostr:')) return
|
||||||
if (replacing === false) 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) {
|
if (response === false) {
|
||||||
replacing = false
|
replacing = false
|
||||||
return
|
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 browser from 'webextension-polyfill'
|
||||||
import React, {useState, useCallback, useEffect} from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import {render} from 'react-dom'
|
import { render } from 'react-dom'
|
||||||
import {generatePrivateKey, nip19} from 'nostr-tools'
|
import { generateSecretKey, nip19, utils } from 'nostr-tools'
|
||||||
import QRCode from 'react-qr-code'
|
import QRCode from 'react-qr-code'
|
||||||
import * as Tabs from '@radix-ui/react-tabs'
|
import * as Tabs from '@radix-ui/react-tabs'
|
||||||
import {LogoIcon} from './icons'
|
import { LogoIcon } from './icons'
|
||||||
import {removePermissions} from './common'
|
import { removePermissions } from './common'
|
||||||
import * as Checkbox from '@radix-ui/react-checkbox'
|
import * as Checkbox from '@radix-ui/react-checkbox'
|
||||||
|
|
||||||
function Options() {
|
function Options() {
|
||||||
let [privKey, setPrivKey] = useState('')
|
const [privKey, setPrivKey] = useState('')
|
||||||
let [relays, setRelays] = useState([])
|
const [relays, setRelays] = useState([])
|
||||||
let [newRelayURL, setNewRelayURL] = useState('')
|
const [newRelayURL, setNewRelayURL] = useState('')
|
||||||
let [policies, setPermissions] = useState([])
|
const [policies, setPermissions] = useState([])
|
||||||
let [protocolHandler, setProtocolHandler] = useState('https://njump.me/{raw}')
|
const [protocolHandler, setProtocolHandler] = useState(
|
||||||
let [hidingPrivateKey, hidePrivateKey] = useState(true)
|
'https://njump.me/{raw}'
|
||||||
let [showNotifications, setNotifications] = useState(false)
|
)
|
||||||
let [messages, setMessages] = useState([])
|
const [hidingPrivateKey, hidePrivateKey] = useState(true)
|
||||||
let [handleNostrLinks, setHandleNostrLinks] = useState(false)
|
const [showNotifications, setNotifications] = useState(false)
|
||||||
let [showProtocolHandlerHelp, setShowProtocolHandlerHelp] = useState(false)
|
const [messages, setMessages] = useState([])
|
||||||
let [unsavedChanges, setUnsavedChanges] = 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)
|
messages.push(msg)
|
||||||
setMessages(messages)
|
setMessages(messages)
|
||||||
setTimeout(() => setMessages([]), 3000)
|
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(() => {
|
useEffect(() => {
|
||||||
browser.storage.local
|
browser.storage.local
|
||||||
.get(['private_key', 'relays', 'protocol_handler', 'notifications'])
|
.get(['private_key', 'relays', 'protocol_handler', 'notifications'])
|
||||||
.then(results => {
|
.then((results) => {
|
||||||
if (results.private_key) {
|
if (results.private_key) {
|
||||||
setPrivKey(nip19.nsecEncode(results.private_key))
|
setPrivKey(nip19.nsecEncode(results.private_key))
|
||||||
}
|
}
|
||||||
if (results.relays) {
|
if (results.relays) {
|
||||||
let relaysList = []
|
const relaysList = []
|
||||||
for (let url in results.relays) {
|
for (const url in results.relays) {
|
||||||
relaysList.push({
|
relaysList.push({
|
||||||
url,
|
url,
|
||||||
policy: results.relays[url]
|
policy: results.relays[url]
|
||||||
@@ -57,28 +80,7 @@ function Options() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPermissions()
|
loadPermissions()
|
||||||
}, [])
|
}, [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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen h-screen flex flex-col items-center justify-center">
|
<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="flex flex-col gap-2">
|
||||||
<div className="font-semibold text-base">Preferred Relays:</div>
|
<div className="font-semibold text-base">Preferred Relays:</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{relays.map(({url, policy}, i) => (
|
{relays.map(({ url, policy }, i) => (
|
||||||
<div key={i} className="flex items-center gap-4">
|
<div key={url} className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
value={url}
|
value={url}
|
||||||
onChange={changeRelayURL.bind(null, i)}
|
onChange={changeRelayURL.bind(null, i)}
|
||||||
@@ -205,6 +207,7 @@ function Options() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -240,6 +243,7 @@ function Options() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -258,6 +262,7 @@ function Options() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={removeRelay.bind(null, i)}
|
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"
|
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">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
value={newRelayURL}
|
value={newRelayURL}
|
||||||
onChange={e => setNewRelayURL(e.target.value)}
|
onChange={(e) => setNewRelayURL(e.target.value)}
|
||||||
onKeyDown={e => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') addNewRelay()
|
if (e.key === 'Enter') addNewRelay()
|
||||||
}}
|
}}
|
||||||
placeholder="wss://"
|
placeholder="wss://"
|
||||||
className="flex-1 h-9 bg-transparent border px-3 py-1 border-primary rounded-lg placeholder:text-muted"
|
className="flex-1 h-9 bg-transparent border px-3 py-1 border-primary rounded-lg placeholder:text-muted"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
disabled={!newRelayURL}
|
disabled={!newRelayURL}
|
||||||
onClick={addNewRelay}
|
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"
|
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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{policies.map(
|
{policies.map(
|
||||||
({host, type, accept, conditions, created_at}) => (
|
({ host, type, accept, conditions, created_at }) => (
|
||||||
<tr
|
<tr
|
||||||
key={
|
key={
|
||||||
host + type + accept + JSON.stringify(conditions)
|
host + type + accept + JSON.stringify(conditions)
|
||||||
@@ -344,6 +350,7 @@ function Options() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleRevoke}
|
onClick={handleRevoke}
|
||||||
data-host={host}
|
data-host={host}
|
||||||
data-accept={accept}
|
data-accept={accept}
|
||||||
@@ -358,11 +365,11 @@ function Options() {
|
|||||||
)}
|
)}
|
||||||
{!policies.length && (
|
{!policies.length && (
|
||||||
<tr>
|
<tr>
|
||||||
{Array(5)
|
<td>N/A</td>
|
||||||
.fill('N/A')
|
<td>N/A</td>
|
||||||
.map((v, i) => (
|
<td>N/A</td>
|
||||||
<td key={i}>{v}</td>
|
<td>N/A</td>
|
||||||
))}
|
<td>N/A</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -387,6 +394,7 @@ function Options() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -413,6 +421,7 @@ function Options() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -438,6 +447,7 @@ function Options() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -458,7 +468,10 @@ function Options() {
|
|||||||
onChange={handleChangeProtocolHandler}
|
onChange={handleChangeProtocolHandler}
|
||||||
/>
|
/>
|
||||||
{!showProtocolHandlerHelp && (
|
{!showProtocolHandlerHelp && (
|
||||||
<button onClick={changeShowProtocolHandlerHelp}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={changeShowProtocolHandlerHelp}
|
||||||
|
>
|
||||||
?
|
?
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -487,6 +500,7 @@ examples:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
disabled={!unsavedChanges.length}
|
disabled={!unsavedChanges.length}
|
||||||
onClick={saveChanges}
|
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"
|
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) {
|
async function handleKeyChange(e) {
|
||||||
let key = e.target.value.toLowerCase().trim()
|
const key = e.target.value.toLowerCase().trim()
|
||||||
setPrivKey(key)
|
setPrivKey(key)
|
||||||
addUnsavedChanges('private_key')
|
addUnsavedChanges('private_key')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setPrivKey(nip19.nsecEncode(generatePrivateKey()))
|
const sk = generateSecretKey()
|
||||||
|
setPrivKey(nip19.nsecEncode(utils.bytesToHex(sk)))
|
||||||
addUnsavedChanges('private_key')
|
addUnsavedChanges('private_key')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +532,7 @@ examples:
|
|||||||
let hexOrEmptyKey = privKey
|
let hexOrEmptyKey = privKey
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let {type, data} = nip19.decode(privKey)
|
const { type, data } = nip19.decode(privKey)
|
||||||
if (type === 'nsec') hexOrEmptyKey = data
|
if (type === 'nsec') hexOrEmptyKey = data
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
@@ -544,7 +559,7 @@ examples:
|
|||||||
function changeRelayURL(i, ev) {
|
function changeRelayURL(i, ev) {
|
||||||
setRelays([
|
setRelays([
|
||||||
...relays.slice(0, i),
|
...relays.slice(0, i),
|
||||||
{url: ev.target.value, policy: relays[i].policy},
|
{ url: ev.target.value, policy: relays[i].policy },
|
||||||
...relays.slice(i + 1)
|
...relays.slice(i + 1)
|
||||||
])
|
])
|
||||||
addUnsavedChanges('relays')
|
addUnsavedChanges('relays')
|
||||||
@@ -555,7 +570,7 @@ examples:
|
|||||||
...relays.slice(0, i),
|
...relays.slice(0, i),
|
||||||
{
|
{
|
||||||
url: relays[i].url,
|
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)
|
...relays.slice(i + 1)
|
||||||
])
|
])
|
||||||
@@ -572,7 +587,7 @@ examples:
|
|||||||
if (!newRelayURL.startsWith('wss://')) return
|
if (!newRelayURL.startsWith('wss://')) return
|
||||||
relays.push({
|
relays.push({
|
||||||
url: newRelayURL,
|
url: newRelayURL,
|
||||||
policy: {read: true, write: true}
|
policy: { read: true, write: true }
|
||||||
})
|
})
|
||||||
setRelays(relays)
|
setRelays(relays)
|
||||||
addUnsavedChanges('relays')
|
addUnsavedChanges('relays')
|
||||||
@@ -580,7 +595,7 @@ examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRevoke(e) {
|
async function handleRevoke(e) {
|
||||||
let {host, accept, type} = e.target.dataset
|
const { host, accept, type } = e.target.dataset
|
||||||
if (
|
if (
|
||||||
window.confirm(
|
window.confirm(
|
||||||
`revoke all ${
|
`revoke all ${
|
||||||
@@ -601,14 +616,14 @@ examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestBrowserNotificationPermissions() {
|
async function requestBrowserNotificationPermissions() {
|
||||||
let granted = await browser.permissions.request({
|
const granted = await browser.permissions.request({
|
||||||
permissions: ['notifications']
|
permissions: ['notifications']
|
||||||
})
|
})
|
||||||
if (!granted) setNotifications(false)
|
if (!granted) setNotifications(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveNotifications() {
|
async function saveNotifications() {
|
||||||
await browser.storage.local.set({notifications: showNotifications})
|
await browser.storage.local.set({ notifications: showNotifications })
|
||||||
showMessage('saved notifications!')
|
showMessage('saved notifications!')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,8 +631,8 @@ examples:
|
|||||||
await browser.storage.local.set({
|
await browser.storage.local.set({
|
||||||
relays: Object.fromEntries(
|
relays: Object.fromEntries(
|
||||||
relays
|
relays
|
||||||
.filter(({url}) => url.trim() !== '')
|
.filter(({ url }) => url.trim() !== '')
|
||||||
.map(({url, policy}) => [url.trim(), policy])
|
.map(({ url, policy }) => [url.trim(), policy])
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
showMessage('saved relays!')
|
showMessage('saved relays!')
|
||||||
@@ -641,19 +656,19 @@ examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveNostrProtocolHandlerSettings() {
|
async function saveNostrProtocolHandlerSettings() {
|
||||||
await browser.storage.local.set({protocol_handler: protocolHandler})
|
await browser.storage.local.set({ protocol_handler: protocolHandler })
|
||||||
showMessage('saved protocol handler!')
|
showMessage('saved protocol handler!')
|
||||||
}
|
}
|
||||||
|
|
||||||
function addUnsavedChanges(section) {
|
function addUnsavedChanges(section) {
|
||||||
if (!unsavedChanges.find(s => s === section)) {
|
if (!unsavedChanges.find((s) => s === section)) {
|
||||||
unsavedChanges.push(section)
|
unsavedChanges.push(section)
|
||||||
setUnsavedChanges(unsavedChanges)
|
setUnsavedChanges(unsavedChanges)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveChanges() {
|
async function saveChanges() {
|
||||||
for (let section of unsavedChanges) {
|
for (const section of unsavedChanges) {
|
||||||
switch (section) {
|
switch (section) {
|
||||||
case 'private_key':
|
case 'private_key':
|
||||||
await saveKey()
|
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 = {
|
export const NO_PERMISSIONS_REQUIRED = {
|
||||||
replaceURL: true
|
replaceURL: true,
|
||||||
}
|
peekPublicKey: true,
|
||||||
|
};
|
||||||
|
|
||||||
export const PERMISSION_NAMES = Object.fromEntries([
|
export const PERMISSION_NAMES = Object.fromEntries([
|
||||||
['getPublicKey', 'read your public key'],
|
["getPublicKey", "read your public key"],
|
||||||
['getRelays', 'read your list of preferred relays'],
|
["signEvent", "sign events using your private key"],
|
||||||
['signEvent', 'sign events using your private key'],
|
["nip04.encrypt", "encrypt messages to peers"],
|
||||||
['nip04.encrypt', 'encrypt messages to peers'],
|
["nip04.decrypt", "decrypt messages from peers"],
|
||||||
['nip04.decrypt', 'decrypt messages from peers']
|
["nip44.encrypt", "encrypt messages to peers"],
|
||||||
])
|
["nip44.decrypt", "decrypt messages from peers"],
|
||||||
|
]);
|
||||||
|
|
||||||
function matchConditions(conditions, event) {
|
function matchConditions(conditions, event) {
|
||||||
if (conditions?.kinds) {
|
if (conditions?.kinds) {
|
||||||
if (event.kind in conditions.kinds) return true
|
if (event.kind in conditions.kinds) return true;
|
||||||
else return false
|
else return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPermissionStatus(host, type, event) {
|
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++) {
|
for (let i = 0; i < answers.length; i++) {
|
||||||
let accept = answers[i]
|
const accept = answers[i];
|
||||||
let {conditions} = policies?.[host]?.[accept]?.[type] || {}
|
const { conditions } = policies?.[host]?.[accept]?.[type] || {};
|
||||||
|
|
||||||
if (conditions) {
|
if (conditions) {
|
||||||
if (type === 'signEvent') {
|
if (type === "signEvent") {
|
||||||
if (matchConditions(conditions, event)) {
|
if (matchConditions(conditions, event)) {
|
||||||
return accept // may be true or false
|
return accept; // may be true or false
|
||||||
} else {
|
} 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 {
|
} 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) {
|
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 the new conditions is "match everything", override the previous
|
||||||
if (Object.keys(conditions).length === 0) {
|
if (Object.keys(conditions).length === 0) {
|
||||||
conditions = {}
|
conditions = {};
|
||||||
} else {
|
} else {
|
||||||
// if we already had a policy for this, merge the conditions
|
// 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) {
|
||||||
if (existingConditions.kinds && conditions.kinds) {
|
if (existingConditions.kinds && conditions.kinds) {
|
||||||
Object.keys(existingConditions.kinds).forEach(kind => {
|
Object.keys(existingConditions.kinds).forEach((kind) => {
|
||||||
conditions.kinds[kind] = true
|
conditions.kinds[kind] = true;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
|
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
|
||||||
let other = !accept
|
const other = !accept;
|
||||||
let reverse = policies?.[host]?.[other]?.[type]
|
const reverse = policies?.[host]?.[other]?.[type];
|
||||||
if (
|
if (
|
||||||
reverse &&
|
reverse &&
|
||||||
JSON.stringify(reverse.conditions) === JSON.stringify(conditions)
|
JSON.stringify(reverse.conditions) === JSON.stringify(conditions)
|
||||||
) {
|
) {
|
||||||
delete policies[host][other][type]
|
delete policies[host][other][type];
|
||||||
}
|
}
|
||||||
|
|
||||||
// insert our new policy
|
// insert our new policy
|
||||||
policies[host] = policies[host] || {}
|
policies[host] = policies[host] || {};
|
||||||
policies[host][accept] = policies[host][accept] || {}
|
policies[host][accept] = policies[host][accept] || {};
|
||||||
policies[host][accept][type] = {
|
policies[host][accept][type] = {
|
||||||
conditions, // filter that must match the event (in case of signEvent)
|
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) {
|
export async function removePermissions(host, accept, type) {
|
||||||
let {policies = {}} = await browser.storage.local.get('policies')
|
const { policies = {} } = await browser.storage.local.get("policies");
|
||||||
delete policies[host]?.[accept]?.[type]
|
delete policies[host]?.[accept]?.[type];
|
||||||
browser.storage.local.set({policies})
|
browser.storage.local.set({ policies });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function showNotification(host, answer, type, params) {
|
export async function showNotification(host, answer, type, params) {
|
||||||
let ok = await browser.storage.local.get('notifications')
|
const { notifications } = await browser.storage.local.get("notifications");
|
||||||
if (ok) {
|
if (notifications) {
|
||||||
let action = answer ? 'allowed' : 'denied'
|
const action = answer ? "allowed" : "denied";
|
||||||
browser.notifications.create(undefined, {
|
browser.notifications.create(undefined, {
|
||||||
type: 'basic',
|
type: "basic",
|
||||||
title: `${type} ${action} for ${host}`,
|
title: `${type} ${action} for ${host}`,
|
||||||
message: JSON.stringify(
|
message: JSON.stringify(
|
||||||
params?.event
|
params?.event
|
||||||
? {
|
? {
|
||||||
kind: params.event.kind,
|
kind: params.event.kind,
|
||||||
content: params.event.content,
|
content: params.event.content,
|
||||||
tags: params.event.tags
|
tags: params.event.tags,
|
||||||
}
|
}
|
||||||
: params,
|
: params,
|
||||||
null,
|
null,
|
||||||
2
|
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",
|
"description": "Nostr Signer Extension",
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"homepage_url": "https://github.com/reyamir/nostr-connect",
|
"homepage_url": "https://github.com/reyamir/nostr-connect",
|
||||||
"manifest_version": 2,
|
"manifest_version": 3,
|
||||||
"browser_specific_settings": {
|
|
||||||
"gecko": {
|
|
||||||
"id": "{e665d138-0e5b-4b7a-ab91-7af834eda7a2}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
"32": "icons/icon32.png",
|
"32": "icons/icon32.png",
|
||||||
@@ -17,19 +12,25 @@
|
|||||||
},
|
},
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["background.build.js"]
|
"service_worker": "background.build.js"
|
||||||
},
|
},
|
||||||
"browser_action": {
|
"action": {
|
||||||
"default_title": "Nostr Connect",
|
"default_title": "Nostr Connect",
|
||||||
"default_popup": "popup.html"
|
"default_popup": "popup.html"
|
||||||
},
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": ["<all_urls>"],
|
"matches": ["<all_urls>"],
|
||||||
"js": ["content-script.build.js"]
|
"js": ["content-script.build.js"],
|
||||||
|
"all_frames": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"permissions": ["storage"],
|
"permissions": ["storage"],
|
||||||
"optional_permissions": ["notifications"],
|
"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
|
return this._pubkey
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async peekPublicKey() {
|
||||||
|
return this._call('peekPublicKey', {})
|
||||||
|
},
|
||||||
|
|
||||||
async signEvent(event) {
|
async signEvent(event) {
|
||||||
return this._call('signEvent', {event})
|
return this._call('signEvent', { event })
|
||||||
},
|
},
|
||||||
|
|
||||||
async getRelays() {
|
async getRelays() {
|
||||||
return this._call('getRelays', {})
|
return {}
|
||||||
},
|
},
|
||||||
|
|
||||||
nip04: {
|
nip04: {
|
||||||
async encrypt(peer, plaintext) {
|
async encrypt(peer, plaintext) {
|
||||||
return window.nostr._call('nip04.encrypt', {peer, plaintext})
|
return window.nostr._call('nip04.encrypt', { peer, plaintext })
|
||||||
},
|
},
|
||||||
|
|
||||||
async decrypt(peer, ciphertext) {
|
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) {
|
_call(type, params) {
|
||||||
let id = Math.random().toString().slice(-4)
|
const id = Math.random().toString().slice(-4)
|
||||||
console.log(
|
console.log(
|
||||||
'%c[nostrconnect:%c' +
|
'%c[nostrconnect:%c' +
|
||||||
id +
|
id +
|
||||||
@@ -46,7 +60,7 @@ window.nostr = {
|
|||||||
'font-weight:bold;color:#90b12d;font-family:monospace'
|
'font-weight:bold;color:#90b12d;font-family:monospace'
|
||||||
)
|
)
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this._requests[id] = {resolve, reject}
|
this._requests[id] = { resolve, reject }
|
||||||
window.postMessage(
|
window.postMessage(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -60,7 +74,7 @@ window.nostr = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('message', message => {
|
window.addEventListener('message', (message) => {
|
||||||
if (
|
if (
|
||||||
!message.data ||
|
!message.data ||
|
||||||
message.data.response === null ||
|
message.data.response === null ||
|
||||||
@@ -71,8 +85,8 @@ window.addEventListener('message', message => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
if (message.data.response.error) {
|
if (message.data.response.error) {
|
||||||
let error = new Error(
|
const error = new Error(
|
||||||
`${EXTENSION}: ` + message.data.response.error.message
|
`${EXTENSION}: ${message.data.response.error.message}`
|
||||||
)
|
)
|
||||||
error.stack = message.data.response.error.stack
|
error.stack = message.data.response.error.stack
|
||||||
window.nostr._requests[message.data.id].reject(error)
|
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 (e.target.tagName !== 'A' || !e.target.href.startsWith('nostr:')) return
|
||||||
if (replacing === false) 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) {
|
if (response === false) {
|
||||||
replacing = false
|
replacing = false
|
||||||
return
|
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 browser from 'webextension-polyfill'
|
||||||
import {render} from 'react-dom'
|
import { render } from 'react-dom'
|
||||||
import {getPublicKey, nip19} from 'nostr-tools'
|
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||||
import React, {useState, useMemo, useEffect} from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import QRCode from 'react-qr-code'
|
import QRCode from 'react-qr-code'
|
||||||
import {SettingsIcon} from './icons'
|
import { SettingsIcon } from './icons'
|
||||||
import {minidenticon} from 'minidenticons'
|
import { minidenticon } from 'minidenticons'
|
||||||
import * as Tabs from '@radix-ui/react-tabs'
|
import * as Tabs from '@radix-ui/react-tabs'
|
||||||
|
|
||||||
function Popup() {
|
function Popup() {
|
||||||
let [keys, setKeys] = useState(null)
|
const [keys, setKeys] = useState(null)
|
||||||
let avatarURI = useMemo(
|
const avatarURI = useMemo(
|
||||||
() =>
|
() =>
|
||||||
keys
|
keys
|
||||||
? 'data:image/svg+xml;utf8,' +
|
? 'data:image/svg+xml;utf8,' +
|
||||||
@@ -25,27 +25,27 @@ function Popup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
browser.storage.local.get(['private_key', 'relays']).then(results => {
|
browser.storage.local.get(['private_key', 'relays']).then((results) => {
|
||||||
if (results.private_key) {
|
if (results.private_key) {
|
||||||
let hexKey = getPublicKey(results.private_key)
|
const hexKey = getPublicKey(results.private_key)
|
||||||
let npubKey = nip19.npubEncode(hexKey)
|
const npubKey = nip19.npubEncode(hexKey)
|
||||||
|
|
||||||
setKeys({npub: npubKey, hex: hexKey})
|
setKeys({ npub: npubKey, hex: hexKey })
|
||||||
|
|
||||||
if (results.relays) {
|
if (results.relays) {
|
||||||
let relaysList = []
|
const relaysList = []
|
||||||
for (let url in results.relays) {
|
for (const url in results.relays) {
|
||||||
if (results.relays[url].write) {
|
if (results.relays[url].write) {
|
||||||
relaysList.push(url)
|
relaysList.push(url)
|
||||||
if (relaysList.length >= 3) break
|
if (relaysList.length >= 3) break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (relaysList.length) {
|
if (relaysList.length) {
|
||||||
let nprofileKey = nip19.nprofileEncode({
|
const nprofileKey = nip19.nprofileEncode({
|
||||||
pubkey: hexKey,
|
pubkey: hexKey,
|
||||||
relays: relaysList
|
relays: relaysList
|
||||||
})
|
})
|
||||||
setKeys(prev => ({...prev, nprofile: nprofileKey}))
|
setKeys((prev) => ({ ...prev, nprofile: nprofileKey }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -71,6 +71,7 @@ function Popup() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
className="w-6 h-6"
|
className="w-6 h-6"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -95,6 +96,7 @@ function Popup() {
|
|||||||
<img
|
<img
|
||||||
src={avatarURI}
|
src={avatarURI}
|
||||||
className="w-9 h-9 rounded-full bg-muted"
|
className="w-9 h-9 rounded-full bg-muted"
|
||||||
|
alt="Avatar"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-9 h-9 rounded-full bg-muted" />
|
<div className="w-9 h-9 rounded-full bg-muted" />
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import browser from 'webextension-polyfill'
|
import browser from 'webextension-polyfill'
|
||||||
import {render} from 'react-dom'
|
import { render } from 'react-dom'
|
||||||
import React, {useState} from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import {PERMISSION_NAMES} from './common'
|
import { PERMISSION_NAMES } from './common'
|
||||||
import {LogoIcon} from './icons'
|
import { LogoIcon } from './icons'
|
||||||
import * as Checkbox from '@radix-ui/react-checkbox'
|
import * as Checkbox from '@radix-ui/react-checkbox'
|
||||||
|
|
||||||
function Prompt() {
|
function Prompt() {
|
||||||
const [isRemember, setIsRemember] = useState(false)
|
const [isRemember, setIsRemember] = useState(false)
|
||||||
|
|
||||||
let qs = new URLSearchParams(location.search)
|
const qs = new URLSearchParams(location.search)
|
||||||
let id = qs.get('id')
|
const id = qs.get('id')
|
||||||
let host = qs.get('host')
|
const host = qs.get('host')
|
||||||
let type = qs.get('type')
|
const type = qs.get('type')
|
||||||
let params, event
|
let params, event
|
||||||
|
|
||||||
try {
|
try {
|
||||||
params = JSON.parse(qs.get('params'))
|
params = JSON.parse(qs.get('params'))
|
||||||
if (Object.keys(params).length === 0) params = null
|
if (Object.keys(params).length === 0) params = null
|
||||||
else if (params.event) event = params.event
|
else if (params.event) event = params.event
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
params = null
|
params = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function authorizeHandler(accept) {
|
function authorizeHandler(accept) {
|
||||||
const conditions = isRemember ? {} : null
|
const conditions = isRemember ? {} : null
|
||||||
return function (ev) {
|
return (ev) => {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
browser.runtime.sendMessage({
|
browser.runtime.sendMessage({
|
||||||
prompt: true,
|
prompt: true,
|
||||||
@@ -73,6 +73,7 @@ function Prompt() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -88,12 +89,14 @@ function Prompt() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={authorizeHandler(false)}
|
onClick={authorizeHandler(false)}
|
||||||
className="flex-1 h-10 rounded-lg shadow-sm border border-primary inline-flex items-center justify-center font-semibold"
|
className="flex-1 h-10 rounded-lg shadow-sm border border-primary inline-flex items-center justify-center font-semibold"
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={authorizeHandler(true)}
|
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"
|
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",
|
"license": "WTFPL",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"async-mutex": "^0.3.2",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"esbuild": "^0.14.54",
|
"async-mutex": "^0.3.2",
|
||||||
"eslint": "^8.54.0",
|
"esbuild": "^0.14.54",
|
||||||
"eslint-plugin-babel": "^5.3.1",
|
"events": "^3.3.0",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"minidenticons": "^4.2.1",
|
||||||
"events": "^3.3.0",
|
"nostr-tools": "^2.8.1",
|
||||||
"minidenticons": "^4.2.0",
|
"react": "^17.0.2",
|
||||||
"nostr-tools": "^1.17.0",
|
"react-dom": "^17.0.2",
|
||||||
"prettier": "^2.8.8",
|
"react-native-svg": "^13.14.1",
|
||||||
"react": "^17.0.2",
|
"react-qr-code": "^2.0.18",
|
||||||
"react-dom": "^17.0.2",
|
"use-boolean-state": "^1.0.2",
|
||||||
"react-native-svg": "^13.14.0",
|
"use-debounce": "^7.0.1",
|
||||||
"react-qr-code": "^2.0.12",
|
"webextension-polyfill": "^0.8.0"
|
||||||
"use-boolean-state": "^1.0.2",
|
},
|
||||||
"use-debounce": "^7.0.1",
|
"scripts": {
|
||||||
"webextension-polyfill": "^0.8.0"
|
"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",
|
||||||
"scripts": {
|
"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",
|
||||||
"dev": "./build.js; pnpm exec tailwindcss -i ./extension/style.css -o ./extension/build/style.css --watch",
|
"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",
|
||||||
"build": "pnpm exec tailwindcss -i ./extension/style.css -o ./extension/output/style.css; ./build.js prod",
|
"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",
|
||||||
"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",
|
"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",
|
||||||
"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"
|
"test": "vitest run",
|
||||||
},
|
"test:watch": "vitest",
|
||||||
"devDependencies": {
|
"test:coverage": "vitest run --coverage"
|
||||||
"esbuild-plugin-copy": "^2.1.1",
|
},
|
||||||
"tailwindcss": "^3.3.5"
|
"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