init develop branch
@@ -1,25 +1,39 @@
|
||||
const webpack = require('webpack');
|
||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
||||
const webpack = require('webpack')
|
||||
const path = require('path')
|
||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin')
|
||||
|
||||
module.exports = function override(config) {
|
||||
const fallback = config.resolve.fallback || {};
|
||||
const fallback = config.resolve.fallback || {}
|
||||
Object.assign(fallback, {
|
||||
"crypto": require.resolve("crypto-browserify"),
|
||||
"stream": require.resolve("stream-browserify"),
|
||||
"assert": require.resolve("assert"),
|
||||
"http": require.resolve("stream-http"),
|
||||
"https": require.resolve("https-browserify"),
|
||||
"os": require.resolve("os-browserify"),
|
||||
"url": require.resolve("url")
|
||||
crypto: require.resolve('crypto-browserify'),
|
||||
stream: require.resolve('stream-browserify'),
|
||||
assert: require.resolve('assert'),
|
||||
http: require.resolve('stream-http'),
|
||||
https: require.resolve('https-browserify'),
|
||||
os: require.resolve('os-browserify'),
|
||||
url: require.resolve('url'),
|
||||
})
|
||||
config.resolve.fallback = fallback;
|
||||
config.resolve.fallback = fallback
|
||||
config.plugins = (config.plugins || []).concat([
|
||||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser',
|
||||
Buffer: ['buffer', 'Buffer']
|
||||
})
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
}),
|
||||
])
|
||||
config.module.rules.unshift({
|
||||
test: /\.m?js$/,
|
||||
resolve: {
|
||||
fullySpecified: false, // disable the behavior
|
||||
},
|
||||
})
|
||||
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
|
||||
config.resolve.plugins = config.resolve.plugins.filter(plugin => !(plugin instanceof ModuleScopePlugin));
|
||||
return config;
|
||||
config.resolve.plugins = config.resolve.plugins.filter((plugin) => {
|
||||
return !(plugin instanceof ModuleScopePlugin)
|
||||
})
|
||||
|
||||
config.resolve.alias = {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
1998
package-lock.json
generated
14
package.json
@@ -3,7 +3,12 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.14.19",
|
||||
"@mui/material": "^5.14.20",
|
||||
"@nostr-dev-kit/ndk": "^2.0.5",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
@@ -14,9 +19,13 @@
|
||||
"crypto": "^1.0.1",
|
||||
"dexie": "^3.2.4",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"notistack": "^3.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.0.3",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"typescript": "^5.3.2",
|
||||
"web-vitals": "^2.1.4",
|
||||
"workbox-background-sync": "^6.6.0",
|
||||
@@ -41,7 +50,8 @@
|
||||
"start": "react-app-rewired start",
|
||||
"build": "react-app-rewired build",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-app-rewired eject"
|
||||
"eject": "react-app-rewired eject",
|
||||
"serve": "npm run build && serve -s build"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
@@ -65,10 +75,12 @@
|
||||
"assert": "^2.1.0",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"customize-cra": "^1.0.0",
|
||||
"https-browserify": "^1.0.0",
|
||||
"os-browserify": "^0.3.0",
|
||||
"process": "^0.11.10",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"serve": "^14.2.1",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"stream-http": "^3.2.0",
|
||||
"url": "^0.11.3"
|
||||
|
BIN
public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 502 B |
BIN
public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
@@ -5,13 +5,34 @@
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site created using create-react-app" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="%PUBLIC_URL%/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="%PUBLIC_URL%/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="%PUBLIC_URL%/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
@@ -21,7 +42,7 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Noauth</title>
|
||||
<title>Nsec.app</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
@@ -35,6 +56,5 @@
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
--></body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 9.4 KiB |
@@ -1,25 +1,20 @@
|
||||
{
|
||||
"short_name": "Noauth",
|
||||
"name": "Noauth Nostr key manager",
|
||||
"name": "Noauth",
|
||||
"short_name": "Noauth Nostr key manager",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
@@ -1,3 +0,0 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
285
src/App.tsx
@@ -1,27 +1,34 @@
|
||||
import './App.css';
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { DbApp, DbKey, DbPending, DbPerm, dbi } from './db';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { swicCall, swicOnRender } from './swic';
|
||||
import { NIP46_RELAYS } from './consts';
|
||||
import { DbPending, dbi } from './modules/db'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { swicOnRender } from './modules/swic'
|
||||
import { useAppDispatch } from './store/hooks/redux'
|
||||
import {
|
||||
setApps,
|
||||
setKeys,
|
||||
setPending,
|
||||
setPerms,
|
||||
} from './store/reducers/content.slice'
|
||||
import AppRoutes from './routes/AppRoutes'
|
||||
import { ndk } from './modules/nostr'
|
||||
|
||||
function App() {
|
||||
|
||||
const [render, setRender] = useState(0)
|
||||
const [keys, setKeys] = useState<DbKey[]>([])
|
||||
const [apps, setApps] = useState<DbApp[]>([])
|
||||
const [perms, setPerms] = useState<DbPerm[]>([])
|
||||
const [pending, setPending] = useState<DbPending[]>([])
|
||||
// const [keys, setKeys] = useState<DbKey[]>([])
|
||||
// const [apps, setApps] = useState<DbApp[]>([])
|
||||
// const [perms, setPerms] = useState<DbPerm[]>([])
|
||||
// const [pending, setPending] = useState<DbPending[]>([])
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const load = async () => {
|
||||
const keys = await dbi.listKeys()
|
||||
setKeys(keys)
|
||||
dispatch(setKeys({ keys }))
|
||||
|
||||
const apps = await dbi.listApps()
|
||||
setApps(apps)
|
||||
dispatch(setApps({ apps }))
|
||||
|
||||
const perms = await dbi.listPerms()
|
||||
setPerms(perms)
|
||||
dispatch(setPerms({ perms }))
|
||||
|
||||
const pending = await dbi.listPending()
|
||||
const firstPending = new Map<string, DbPending>()
|
||||
@@ -32,198 +39,98 @@ function App() {
|
||||
|
||||
// @ts-ignore
|
||||
setPending([...firstPending.values()])
|
||||
dispatch(setPending({ pending }))
|
||||
|
||||
// rerender
|
||||
setRender(r => r + 1)
|
||||
setRender((r) => r + 1)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
// eslint-disable-next-line
|
||||
}, [render])
|
||||
|
||||
async function log(s: string) {
|
||||
const log = document.getElementById('log')
|
||||
if (log) log.innerHTML = s
|
||||
}
|
||||
useEffect(() => {
|
||||
ndk.connect().then(() => console.log('NDK connected'))
|
||||
}, [])
|
||||
|
||||
async function askNotificationPermission() {
|
||||
return new Promise<void>((ok, rej) => {
|
||||
// Let's check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
log("This browser does not support notifications.")
|
||||
rej()
|
||||
} else {
|
||||
Notification.requestPermission().then(() => {
|
||||
log("notifications perm" + Notification.permission)
|
||||
if (Notification.permission === 'granted') ok()
|
||||
else rej()
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
// async function askNotificationPermission() {
|
||||
// return new Promise<void>((ok, rej) => {
|
||||
// // Let's check if the browser supports notifications
|
||||
// if (!('Notification' in window)) {
|
||||
// log('This browser does not support notifications.')
|
||||
// rej()
|
||||
// } else {
|
||||
// Notification.requestPermission().then(() => {
|
||||
// log('notifications perm' + Notification.permission)
|
||||
// if (Notification.permission === 'granted') ok()
|
||||
// else rej()
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
async function enableNotifications() {
|
||||
await askNotificationPermission()
|
||||
try {
|
||||
const r = await swicCall('enablePush')
|
||||
if (!r) {
|
||||
log(`Failed to enable push subscription`)
|
||||
return
|
||||
}
|
||||
// async function enableNotifications() {
|
||||
// await askNotificationPermission()
|
||||
// try {
|
||||
// const r = await swicCall('enablePush')
|
||||
// if (!r) {
|
||||
// log(`Failed to enable push subscription`)
|
||||
// return
|
||||
// }
|
||||
|
||||
log(`enabled!`)
|
||||
} catch (e) {
|
||||
log(`Error: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function call(cb: () => any) {
|
||||
try {
|
||||
return await cb()
|
||||
} catch (e) {
|
||||
log(`Error: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateKey() {
|
||||
call(async () => {
|
||||
const k: any = await swicCall('generateKey');
|
||||
log("New key " + k.npub)
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmPending(id: string, allow: boolean, remember: boolean) {
|
||||
call(async () => {
|
||||
await swicCall('confirm', id, allow, remember);
|
||||
console.log("confirmed", id, allow, remember)
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteApp(appNpub: string) {
|
||||
call(async () => {
|
||||
await swicCall('deleteApp', appNpub);
|
||||
log('App deleted')
|
||||
})
|
||||
}
|
||||
|
||||
async function deletePerm(id: string) {
|
||||
call(async () => {
|
||||
await swicCall('deletePerm', id);
|
||||
log('Perm deleted')
|
||||
})
|
||||
}
|
||||
|
||||
async function saveKey(npub: string) {
|
||||
call(async () => {
|
||||
// @ts-ignore
|
||||
const passphrase = document.getElementById(`passphrase${npub}`)?.value
|
||||
await swicCall('saveKey', npub, passphrase)
|
||||
log('Key saved')
|
||||
})
|
||||
}
|
||||
|
||||
async function importKey() {
|
||||
call(async () => {
|
||||
// @ts-ignore
|
||||
const nsec = document.getElementById(`nsec`)?.value
|
||||
await swicCall('importKey', nsec)
|
||||
log('Key imported')
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchNewKey() {
|
||||
call(async () => {
|
||||
// @ts-ignore
|
||||
const npub = document.getElementById('npub')?.value
|
||||
// @ts-ignore
|
||||
const passphrase = document.getElementById('passphrase')?.value
|
||||
console.log("fetch", npub, passphrase)
|
||||
const k: any = await swicCall('fetchKey', npub, passphrase)
|
||||
log("Fetched " + k.npub)
|
||||
})
|
||||
}
|
||||
// log(`enabled!`)
|
||||
// } catch (e) {
|
||||
// log(`Error: ${e}`)
|
||||
// }
|
||||
// }
|
||||
|
||||
// subscribe to updates from the service worker
|
||||
swicOnRender(() => {
|
||||
console.log("render")
|
||||
setRender(r => r + 1)
|
||||
console.log('render')
|
||||
setRender((r) => r + 1)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
Nostr Login
|
||||
</header>
|
||||
<div>
|
||||
<h4>Keys:</h4>
|
||||
{keys.map((k) => {
|
||||
const { data: pubkey } = nip19.decode(k.npub)
|
||||
const str = `bunker://${pubkey}?relay=${NIP46_RELAYS[0]}`
|
||||
return (
|
||||
<div key={k.npub} style={{ marginBottom: "10px" }}>
|
||||
{k.npub}
|
||||
<div>{str}</div>
|
||||
<div>
|
||||
<input id={`passphrase${k.npub}`} placeholder='save password' />
|
||||
<button onClick={() => saveKey(k.npub)}>save</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
return <AppRoutes />
|
||||
|
||||
<div>
|
||||
<button onClick={generateKey}>generate key</button>
|
||||
</div>
|
||||
<div>
|
||||
<input id='nsec' placeholder='nsec' />
|
||||
<button onClick={importKey}>import key (DANGER!)</button>
|
||||
</div>
|
||||
<div>
|
||||
<input id='npub' placeholder='npub' />
|
||||
<input id='passphrase' placeholder='password' />
|
||||
<button onClick={fetchNewKey}>fetch key</button>
|
||||
</div>
|
||||
<hr />
|
||||
// return (
|
||||
// <div>
|
||||
// <div>
|
||||
// <h4>Connected apps:</h4>
|
||||
// {apps.map((a) => (
|
||||
// <div key={a.npub} style={{ marginTop: '10px' }}>
|
||||
// <div>
|
||||
// {a.npub} => {a.appNpub}
|
||||
// <button onClick={() => deleteApp(a.appNpub)}>
|
||||
// x
|
||||
// </button>
|
||||
// </div>
|
||||
// <h5>Perms:</h5>
|
||||
// {perms
|
||||
// .filter((p) => p.appNpub === a.appNpub)
|
||||
// .map((p) => (
|
||||
// <div key={p.id}>
|
||||
// {p.perm}: {p.value}
|
||||
// <button onClick={() => deletePerm(p.id)}>
|
||||
// x
|
||||
// </button>
|
||||
// </div>
|
||||
// ))}
|
||||
// <hr />
|
||||
// </div>
|
||||
// ))}
|
||||
|
||||
<h4>Connected apps:</h4>
|
||||
{apps.map((a) => (
|
||||
<div key={a.npub} style={{ marginTop: "10px" }}>
|
||||
<div>
|
||||
{a.npub} => {a.appNpub}
|
||||
<button onClick={() => deleteApp(a.appNpub)}>x</button>
|
||||
</div>
|
||||
<h5>Perms:</h5>
|
||||
{perms.filter(p => p.appNpub === a.appNpub).map(p => (
|
||||
<div key={p.id}>
|
||||
{p.perm}: {p.value}
|
||||
<button onClick={() => deletePerm(p.id)}>x</button>
|
||||
</div>
|
||||
))}
|
||||
<hr />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h4>Pending requests:</h4>
|
||||
{pending.map((p) => (
|
||||
<div key={p.id}>
|
||||
{p.appNpub} => {p.npub} ({p.method})
|
||||
<button onClick={() => confirmPending(p.id, true, false)}>yes</button>
|
||||
<button onClick={() => confirmPending(p.id, false, false)}>no</button>
|
||||
<button onClick={() => confirmPending(p.id, true, true)}>yes all</button>
|
||||
<button onClick={() => confirmPending(p.id, false, true)}>no all</button>
|
||||
</div>
|
||||
))}
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<button onClick={enableNotifications}>enable background signing</button>
|
||||
</div>
|
||||
<div>
|
||||
<textarea id='log'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// <div>
|
||||
// <button onClick={enableNotifications}>
|
||||
// enable background signing
|
||||
// </button>
|
||||
// </div>
|
||||
// <div>
|
||||
// <textarea id='log'></textarea>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// )
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App
|
||||
|
3
src/assets/icons/logo.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M25.5479 20.2413C24.4588 19.0649 22.6437 18.9473 21.3126 19.7708L18.8925 17.8885C19.8606 16.2416 19.9816 14.2417 19.0135 12.5948L21.4337 10.8302C23.0067 12.1242 25.3059 12.0066 26.7579 10.5949C28.331 9.06555 28.452 6.59511 26.8789 5.06579C25.3059 3.53647 22.7647 3.41883 21.1916 4.94815C19.9816 6.12455 19.6186 7.88915 20.3446 9.41847L18.0455 11.1831C16.1094 9.41847 13.0842 9.18319 11.0271 10.5949L8.72796 8.35971C10.059 6.59511 9.93803 4.00703 8.24394 2.36007C6.42884 0.477835 3.28267 0.477835 1.46757 2.24243C-0.468534 4.00703 -0.468534 7.06567 1.34656 8.83027C3.04066 10.4772 5.58179 10.7125 7.5179 9.53611L9.81702 11.7713C8.36494 13.7712 8.36495 16.4769 9.93803 18.3591L7.27589 21.1825C6.06582 20.5943 4.61374 20.8295 3.64569 21.7707C2.43562 22.9471 2.31462 24.9469 3.52468 26.1233C4.73475 27.2997 6.79186 27.4174 8.00192 26.241C9.09098 25.1822 9.21199 23.6529 8.48595 22.4765L11.0271 19.6531C13.0842 20.9472 15.8674 20.8295 17.6824 19.1826L20.1026 21.0648C19.6186 22.2412 19.7396 23.6529 20.7076 24.594C21.9177 25.8881 24.0958 25.8881 25.3059 24.7117C26.7579 23.5353 26.7579 21.5354 25.5479 20.2413Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
3
src/assets/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ReactComponent as AppLogo } from './icons/logo.svg'
|
||||
|
||||
export { AppLogo }
|
778
src/backend.ts
@@ -1,778 +0,0 @@
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'
|
||||
import { dbi, DbKey, DbPending, DbPerm } from './db'
|
||||
import { Keys } from './keys'
|
||||
import NDK, { IEventHandlingStrategy, NDKEvent, NDKNip46Backend, NDKPrivateKeySigner, NDKSigner } from '@nostr-dev-kit/ndk'
|
||||
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS } from './consts'
|
||||
import { Nip04 } from './nip04'
|
||||
//import { PrivateKeySigner } from './signer'
|
||||
|
||||
//const PERF_TEST = false
|
||||
|
||||
export interface KeyInfo {
|
||||
npub: string
|
||||
nip05?: string
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
interface Key {
|
||||
npub: string
|
||||
ndk: NDK
|
||||
backoff: number
|
||||
signer: NDKSigner
|
||||
backend: NDKNip46Backend
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
req: DbPending
|
||||
cb: (allow: boolean, remember: boolean) => void
|
||||
}
|
||||
|
||||
interface IAllowCallbackParams {
|
||||
npub: string,
|
||||
id: string,
|
||||
method: string,
|
||||
remotePubkey: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
params?: any
|
||||
}
|
||||
|
||||
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
|
||||
|
||||
private privkey: string
|
||||
private nip04 = new Nip04()
|
||||
|
||||
constructor(privkey: string) {
|
||||
this.privkey = privkey
|
||||
}
|
||||
|
||||
private async getKey(
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
recipientPubkey: string
|
||||
) {
|
||||
if (
|
||||
!(await backend.pubkeyAllowed({
|
||||
id,
|
||||
pubkey: remotePubkey,
|
||||
// @ts-ignore
|
||||
method: "get_nip04_key",
|
||||
params: recipientPubkey,
|
||||
}))
|
||||
) {
|
||||
backend.debug(`get_nip04_key request from ${remotePubkey} rejected`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Buffer.from(
|
||||
this.nip04.createKey(this.privkey, recipientPubkey)
|
||||
).toString('hex')
|
||||
}
|
||||
|
||||
async handle(
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
params: string[]
|
||||
) {
|
||||
const [recipientPubkey] = params
|
||||
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
|
||||
}
|
||||
}
|
||||
|
||||
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
|
||||
readonly npub: string
|
||||
readonly method: string
|
||||
private body: IEventHandlingStrategy
|
||||
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
||||
|
||||
constructor(
|
||||
npub: string,
|
||||
method: string,
|
||||
body: IEventHandlingStrategy,
|
||||
allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
||||
) {
|
||||
this.npub = npub
|
||||
this.method = method
|
||||
this.body = body
|
||||
this.allowCb = allowCb
|
||||
}
|
||||
|
||||
async handle(
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
params: string[]
|
||||
): Promise<string | undefined> {
|
||||
console.log(Date.now(), "handle", { method: this.method, id, remotePubkey, params })
|
||||
const allow = await this.allowCb({
|
||||
npub: this.npub,
|
||||
id,
|
||||
method: this.method,
|
||||
remotePubkey,
|
||||
params
|
||||
})
|
||||
if (!allow) return undefined
|
||||
return this.body.handle(backend, id, remotePubkey, params)
|
||||
.then(r => {
|
||||
console.log(Date.now(), "req", id, "method", this.method, "result", r)
|
||||
return r
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class NoauthBackend {
|
||||
readonly swg: ServiceWorkerGlobalScope
|
||||
private keysModule: Keys
|
||||
private enckeys: DbKey[] = []
|
||||
private keys: Key[] = []
|
||||
private perms: DbPerm[] = []
|
||||
private doneReqIds: string[] = []
|
||||
private confirmBuffer: Pending[] = []
|
||||
private accessBuffer: DbPending[] = []
|
||||
private notifCallback: (() => void) | null = null
|
||||
|
||||
public constructor(swg: ServiceWorkerGlobalScope) {
|
||||
this.swg = swg
|
||||
this.keysModule = new Keys(swg.crypto.subtle)
|
||||
|
||||
const self = this
|
||||
swg.addEventListener('activate', (event) => {
|
||||
console.log("activate")
|
||||
})
|
||||
|
||||
swg.addEventListener('install', (event) => {
|
||||
console.log("install")
|
||||
})
|
||||
|
||||
swg.addEventListener('push', (event) => {
|
||||
console.log("got push", event)
|
||||
self.onPush(event)
|
||||
event.waitUntil(new Promise((ok: any) => {
|
||||
self.setNotifCallback(ok)
|
||||
}))
|
||||
})
|
||||
|
||||
swg.addEventListener('message', (event) => {
|
||||
self.onMessage(event)
|
||||
})
|
||||
|
||||
swg.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
if (event.action.startsWith("allow:")) {
|
||||
self.confirm(event.action.split(':')[1], true, false)
|
||||
} else if (event.action.startsWith("allow-remember:")) {
|
||||
self.confirm(event.action.split(':')[1], true, true)
|
||||
} else if (event.action.startsWith("disallow:")) {
|
||||
self.confirm(event.action.split(':')[1], false, false)
|
||||
} else {
|
||||
|
||||
event.waitUntil(
|
||||
self.swg.clients.matchAll({ type: "window" })
|
||||
.then((clientList) => {
|
||||
console.log("clients", clientList.length)
|
||||
for (const client of clientList) {
|
||||
console.log("client", client.url)
|
||||
if (new URL(client.url).pathname === "/" && "focus" in client)
|
||||
return client.focus();
|
||||
}
|
||||
// if (self.swg.clients.openWindow)
|
||||
// return self.swg.clients.openWindow("/");
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
false // ???
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.enckeys = await dbi.listKeys()
|
||||
console.log("started encKeys", this.listKeys())
|
||||
this.perms = await dbi.listPerms()
|
||||
console.log("started perms", this.perms)
|
||||
|
||||
const sub = await this.swg.registration.pushManager.getSubscription()
|
||||
|
||||
for (const k of this.enckeys) {
|
||||
await this.unlock(k.npub)
|
||||
|
||||
// ensure we're subscribed on the server
|
||||
if (sub)
|
||||
await this.sendSubscriptionToServer(k.npub, sub)
|
||||
}
|
||||
}
|
||||
|
||||
public setNotifCallback(cb: () => void) {
|
||||
if (this.notifCallback) {
|
||||
this.notify()
|
||||
}
|
||||
this.notifCallback = cb
|
||||
}
|
||||
|
||||
public listKeys(): KeyInfo[] {
|
||||
return this.enckeys.map<KeyInfo>((k) => this.keyInfo(k))
|
||||
}
|
||||
|
||||
public isLocked(npub: string): boolean {
|
||||
return !this.keys.find(k => k.npub === npub)
|
||||
}
|
||||
|
||||
public hasKey(npub: string): boolean {
|
||||
return !!this.enckeys.find(k => k.npub === npub)
|
||||
}
|
||||
|
||||
private async sha256(s: string) {
|
||||
return Buffer.from(
|
||||
await this.swg.crypto.subtle.digest('SHA-256', Buffer.from(s)))
|
||||
.toString('hex')
|
||||
}
|
||||
|
||||
private async sendPost({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body
|
||||
}: {
|
||||
url: string,
|
||||
method: string,
|
||||
headers: any,
|
||||
body: string
|
||||
}) {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (r.status !== 200 && r.status !== 201) {
|
||||
console.log("Fetch error", url, method, r.status)
|
||||
throw new Error("Failed to fetch" + url)
|
||||
}
|
||||
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
private async sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method = 'GET',
|
||||
body = ''
|
||||
}: {
|
||||
npub: string,
|
||||
url: string,
|
||||
method: string,
|
||||
body: string
|
||||
}) {
|
||||
|
||||
const { data: pubkey } = nip19.decode(npub)
|
||||
|
||||
const key = this.keys.find(k => k.npub === npub)
|
||||
if (!key) throw new Error("Unknown key")
|
||||
|
||||
const authEvent = new NDKEvent(key.ndk, {
|
||||
pubkey: pubkey as string,
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method],
|
||||
]
|
||||
})
|
||||
if (body)
|
||||
authEvent.tags.push(['payload', await this.sha256(body)])
|
||||
|
||||
authEvent.sig = await authEvent.sign(key.signer)
|
||||
|
||||
const auth = this.swg.btoa(JSON.stringify(authEvent.rawEvent()))
|
||||
|
||||
return await this.sendPost({
|
||||
url,
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Nostr ${auth}`,
|
||||
},
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
private async sendSubscriptionToServer(
|
||||
npub: string,
|
||||
pushSubscription: PushSubscription
|
||||
) {
|
||||
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
relays: NIP46_RELAYS,
|
||||
pushSubscription
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/subscribe`
|
||||
|
||||
return this.sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method,
|
||||
body
|
||||
})
|
||||
}
|
||||
private async sendKeyToServer(
|
||||
npub: string,
|
||||
enckey: string,
|
||||
pwh: string
|
||||
) {
|
||||
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
data: enckey,
|
||||
pwh
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/put`
|
||||
|
||||
return this.sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method,
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
private async fetchKeyFromServer(
|
||||
npub: string,
|
||||
pwh: string
|
||||
) {
|
||||
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
pwh
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/get`
|
||||
|
||||
return await this.sendPost({
|
||||
url,
|
||||
method,
|
||||
headers: {},
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
private notify() {
|
||||
// FIXME collect info from accessBuffer and confirmBuffer
|
||||
// and update the notifications
|
||||
|
||||
for (const r of this.confirmBuffer) {
|
||||
const text = `Confirm "${r.req.method}" by "${r.req.appNpub}"`
|
||||
this.swg.registration.showNotification('Signer access', {
|
||||
body: text,
|
||||
tag: "confirm-" + r.req.appNpub,
|
||||
actions: [
|
||||
{
|
||||
action: "allow:" + r.req.id,
|
||||
title: "Yes"
|
||||
},
|
||||
{
|
||||
action: "disallow:" + r.req.id,
|
||||
title: "No"
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (this.notifCallback)
|
||||
this.notifCallback()
|
||||
}
|
||||
|
||||
private keyInfo(k: DbKey): KeyInfo {
|
||||
return {
|
||||
npub: k.npub,
|
||||
nip05: k.nip05,
|
||||
locked: this.isLocked(k.npub)
|
||||
}
|
||||
}
|
||||
|
||||
private async generateGoodKey(): Promise<string> {
|
||||
return generatePrivateKey()
|
||||
}
|
||||
|
||||
public async addKey(nsec?: string): Promise<KeyInfo> {
|
||||
let sk = ''
|
||||
if (nsec) {
|
||||
const { type, data } = nip19.decode(nsec)
|
||||
if (type !== 'nsec') throw new Error('Bad nsec')
|
||||
sk = data
|
||||
} else {
|
||||
sk = await this.generateGoodKey()
|
||||
}
|
||||
const pubkey = getPublicKey(sk)
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
const localKey = await this.keysModule.generateLocalKey()
|
||||
const enckey = await this.keysModule.encryptKeyLocal(sk, localKey)
|
||||
// @ts-ignore
|
||||
const dbKey: DbKey = { npub, enckey, localKey }
|
||||
await dbi.addKey(dbKey)
|
||||
this.enckeys.push(dbKey)
|
||||
await this.startKey({ npub, sk })
|
||||
|
||||
const sub = await this.swg.registration.pushManager.getSubscription()
|
||||
if (sub)
|
||||
await this.sendSubscriptionToServer(npub, sub)
|
||||
|
||||
return this.keyInfo(dbKey)
|
||||
}
|
||||
|
||||
private getPerm(req: DbPending): string {
|
||||
return this.perms.find(p => p.npub === req.npub
|
||||
&& p.appNpub === req.appNpub
|
||||
&& p.perm === req.method)?.value || ''
|
||||
}
|
||||
|
||||
private async allowPermitCallback({
|
||||
npub,
|
||||
id,
|
||||
method,
|
||||
remotePubkey,
|
||||
params
|
||||
}: IAllowCallbackParams): Promise<boolean> {
|
||||
|
||||
// same reqs usually come on reconnects
|
||||
if (this.doneReqIds.includes(id)) {
|
||||
console.log("request already done", id)
|
||||
// FIXME maybe repeat the reply, but without the Notification?
|
||||
return false
|
||||
}
|
||||
|
||||
const appNpub = nip19.npubEncode(remotePubkey)
|
||||
const req: DbPending = {
|
||||
id,
|
||||
npub,
|
||||
appNpub,
|
||||
method,
|
||||
params: JSON.stringify(params),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
const self = this
|
||||
return new Promise(async (ok) => {
|
||||
|
||||
// called when it's decided whether to allow this or not
|
||||
const onAllow = async (manual: boolean, allow: boolean, remember: boolean) => {
|
||||
|
||||
// confirm
|
||||
console.log(Date.now(), allow ? "allowed" : "disallowed", npub, method, params)
|
||||
if (manual) {
|
||||
await dbi.confirmPending(id, allow)
|
||||
|
||||
if (!await dbi.getApp(req.appNpub)) {
|
||||
await dbi.addApp({
|
||||
appNpub: req.appNpub,
|
||||
npub: req.npub,
|
||||
timestamp: Date.now(),
|
||||
name: '',
|
||||
icon: '',
|
||||
url: ''
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// just send to db w/o waiting for it
|
||||
// if (!PERF_TEST)
|
||||
dbi.addConfirmed({
|
||||
...req,
|
||||
allowed: allow
|
||||
})
|
||||
}
|
||||
|
||||
// for notifications
|
||||
self.accessBuffer.push(req)
|
||||
|
||||
// clear from pending
|
||||
const index = self.confirmBuffer.findIndex(r => r.req.id === id)
|
||||
if (index >= 0)
|
||||
self.confirmBuffer.splice(index, 1)
|
||||
|
||||
if (remember) {
|
||||
await dbi.addPerm({
|
||||
id: req.id,
|
||||
npub: req.npub,
|
||||
appNpub: req.appNpub,
|
||||
perm: method,
|
||||
value: allow ? '1' : '0',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
this.perms = await dbi.listPerms()
|
||||
|
||||
const otherReqs = self.confirmBuffer.filter(r => r.req.appNpub === req.appNpub)
|
||||
for (const r of otherReqs) {
|
||||
if (r.req.method === req.method) {
|
||||
r.cb(allow, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notify UI that it was confirmed
|
||||
// if (!PERF_TEST)
|
||||
this.updateUI()
|
||||
|
||||
// return to let nip46 flow proceed
|
||||
ok(allow)
|
||||
}
|
||||
|
||||
// check perms
|
||||
const perm = this.getPerm(req)
|
||||
console.log(Date.now(), "perm", req.id, perm)
|
||||
|
||||
// have perm?
|
||||
if (perm) {
|
||||
// reply immediately
|
||||
onAllow(false, perm === '1', false)
|
||||
} else {
|
||||
|
||||
// put pending req to db
|
||||
await dbi.addPending(req)
|
||||
|
||||
// need manual confirmation
|
||||
console.log("need confirm", req)
|
||||
|
||||
// put to a list of pending requests
|
||||
this.confirmBuffer.push({
|
||||
req,
|
||||
cb: (allow, remember) => onAllow(true, allow, remember)
|
||||
})
|
||||
|
||||
// show notifs
|
||||
this.notify()
|
||||
|
||||
// notify main thread to ask for user concent
|
||||
// FIXME show a 'confirm' notification?
|
||||
this.updateUI()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async startKey({ npub, sk, backoff = 1000 }: { npub: string, sk: string, backoff?: number }) {
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: NIP46_RELAYS
|
||||
})
|
||||
|
||||
// init relay objects but dont wait until we connect
|
||||
ndk.connect()
|
||||
|
||||
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
|
||||
const backend = new NDKNip46Backend(ndk, sk, () => Promise.resolve(true))
|
||||
this.keys.push({ npub, backend, signer, ndk, backoff })
|
||||
|
||||
// new method
|
||||
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
|
||||
|
||||
// assign our own permission callback
|
||||
for (const method in backend.handlers) {
|
||||
backend.handlers[method] = new EventHandlingStrategyWrapper(npub, method, backend.handlers[method], this.allowPermitCallback.bind(this))
|
||||
}
|
||||
|
||||
// start
|
||||
backend.start()
|
||||
console.log("started", npub)
|
||||
|
||||
// backoff reset on successfull connection
|
||||
const self = this
|
||||
const onConnect = () => {
|
||||
// reset backoff
|
||||
const key = self.keys.find(k => k.npub === npub)
|
||||
if (key) key.backoff = 0
|
||||
console.log("reset backoff for", npub)
|
||||
}
|
||||
|
||||
// reconnect handling
|
||||
let reconnected = false
|
||||
const onDisconnect = () => {
|
||||
if (reconnected) return
|
||||
if (ndk.pool.connectedRelays().length > 0) return
|
||||
reconnected = true
|
||||
console.log(new Date(), "all relays are down for key", npub)
|
||||
|
||||
// run full restart after a pause
|
||||
const bo = self.keys.find(k => k.npub === npub)?.backoff || 1000
|
||||
setTimeout(() => {
|
||||
console.log(new Date(), "reconnect relays for key", npub, "backoff", bo)
|
||||
// @ts-ignore
|
||||
for (const r of ndk.pool.relays.values())
|
||||
r.disconnect()
|
||||
// make sure it no longer activates
|
||||
backend.handlers = {}
|
||||
|
||||
self.keys = self.keys.filter(k => k.npub !== npub)
|
||||
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
|
||||
}, bo)
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
for (const r of ndk.pool.relays.values()) {
|
||||
r.on('connect', onConnect)
|
||||
r.on('disconnect', onDisconnect)
|
||||
}
|
||||
}
|
||||
|
||||
public async unlock(npub: string) {
|
||||
console.log("unlocking", npub)
|
||||
if (!this.isLocked(npub)) throw new Error(`Key ${npub} already unlocked`)
|
||||
const info = this.enckeys.find(k => k.npub === npub)
|
||||
if (!info) throw new Error(`Key ${npub} not found`)
|
||||
const { type } = nip19.decode(npub)
|
||||
if (type !== "npub") throw new Error(`Invalid npub ${npub}`)
|
||||
const sk = await this.keysModule.decryptKeyLocal({
|
||||
enckey: info.enckey,
|
||||
// @ts-ignore
|
||||
localKey: info.localKey
|
||||
})
|
||||
await this.startKey({ npub, sk })
|
||||
}
|
||||
|
||||
private async generateKey() {
|
||||
const k = await this.addKey()
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
|
||||
private async importKey(nsec: string) {
|
||||
const k = await this.addKey(nsec)
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
|
||||
private async saveKey(npub: string, passphrase: string) {
|
||||
const info = this.enckeys.find(k => k.npub === npub)
|
||||
if (!info) throw new Error(`Key ${npub} not found`)
|
||||
const sk = await this.keysModule.decryptKeyLocal({
|
||||
enckey: info.enckey,
|
||||
// @ts-ignore
|
||||
localKey: info.localKey
|
||||
})
|
||||
const { enckey, pwh } = await this.keysModule.encryptKeyPass({ key: sk, passphrase })
|
||||
await this.sendKeyToServer(npub, enckey, pwh)
|
||||
}
|
||||
|
||||
private async fetchKey(npub: string, passphrase: string) {
|
||||
const { type, data: pubkey } = nip19.decode(npub)
|
||||
if (type !== "npub") throw new Error(`Invalid npub ${npub}`)
|
||||
const { pwh } = await this.keysModule.generatePassKey(pubkey, passphrase)
|
||||
const { data: enckey } = await this.fetchKeyFromServer(npub, pwh);
|
||||
|
||||
// key already exists?
|
||||
const key = this.enckeys.find(k => k.npub === npub)
|
||||
if (key) return this.keyInfo(key)
|
||||
|
||||
// add new key
|
||||
const nsec = await this.keysModule.decryptKeyPass({ pubkey, enckey, passphrase })
|
||||
const k = await this.addKey(nsec)
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
|
||||
private async confirm(id: string, allow: boolean, remember: boolean) {
|
||||
const req = this.confirmBuffer.find(r => r.req.id === id)
|
||||
if (!req) {
|
||||
console.log("req ", id, "not found")
|
||||
|
||||
await dbi.removePending(id)
|
||||
this.updateUI()
|
||||
} else {
|
||||
|
||||
console.log("confirming", id, allow, remember)
|
||||
req.cb(allow, remember)
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteApp(appNpub: string) {
|
||||
this.perms = this.perms.filter(p => p.appNpub !== appNpub)
|
||||
await dbi.removeApp(appNpub)
|
||||
await dbi.removeAppPerms(appNpub)
|
||||
this.updateUI()
|
||||
}
|
||||
|
||||
private async deletePerm(id: string) {
|
||||
this.perms = this.perms.filter(p => p.id !== id)
|
||||
await dbi.removePerm(id)
|
||||
this.updateUI()
|
||||
}
|
||||
|
||||
private async enablePush(): Promise<boolean> {
|
||||
const options = {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: WEB_PUSH_PUBKEY,
|
||||
}
|
||||
|
||||
const pushSubscription = await this.swg.registration.pushManager.subscribe(options);
|
||||
console.log("push endpoint", JSON.stringify(pushSubscription));
|
||||
|
||||
if (!pushSubscription) {
|
||||
console.log("failed to enable push subscription")
|
||||
return false
|
||||
}
|
||||
|
||||
// subscribe to all pubkeys
|
||||
for (const k of this.keys) {
|
||||
await this.sendSubscriptionToServer(k.npub, pushSubscription);
|
||||
}
|
||||
console.log("push enabled")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
public async onMessage(event: any) {
|
||||
const { id, method, args } = event.data
|
||||
try {
|
||||
//console.log("UI message", id, method, args)
|
||||
let result = undefined
|
||||
if (method === 'generateKey') {
|
||||
result = await this.generateKey()
|
||||
} else if (method === 'importKey') {
|
||||
result = await this.importKey(args[0])
|
||||
} else if (method === 'saveKey') {
|
||||
result = await this.saveKey(args[0], args[1])
|
||||
} else if (method === 'fetchKey') {
|
||||
result = await this.fetchKey(args[0], args[1])
|
||||
} else if (method === 'confirm') {
|
||||
result = await this.confirm(args[0], args[1], args[2])
|
||||
} else if (method === 'deleteApp') {
|
||||
result = await this.deleteApp(args[0])
|
||||
} else if (method === 'deletePerm') {
|
||||
result = await this.deletePerm(args[0])
|
||||
} else if (method === 'enablePush') {
|
||||
result = await this.enablePush()
|
||||
} else {
|
||||
console.log("unknown method from UI ", method)
|
||||
}
|
||||
event.source.postMessage({
|
||||
id, result
|
||||
})
|
||||
} catch (e: any) {
|
||||
event.source.postMessage({
|
||||
id, error: e.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async updateUI() {
|
||||
const clients = await this.swg.clients.matchAll()
|
||||
console.log("updateUI clients", clients.length)
|
||||
for (const client of clients) {
|
||||
client.postMessage({})
|
||||
}
|
||||
}
|
||||
|
||||
public async onPush(event: any) {
|
||||
console.log("push", { data: event.data });
|
||||
// noop - we just need browser to launch this worker
|
||||
// FIXME use event.waitUntil and and unblock after we
|
||||
// show a notification
|
||||
}
|
||||
}
|
25
src/components/Notification/Notification.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IconButton, Typography } from '@mui/material'
|
||||
import { forwardRef } from 'react'
|
||||
import { useSnackbar } from 'notistack'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import { NotificationProps } from './types'
|
||||
import { StyledAlert, StyledContainer } from './styled'
|
||||
|
||||
export const Notification = forwardRef<HTMLDivElement, NotificationProps>(
|
||||
({ message, alertvariant, id }, ref) => {
|
||||
const { closeSnackbar } = useSnackbar()
|
||||
|
||||
const closeSnackBarHandler = () => closeSnackbar(id)
|
||||
|
||||
return (
|
||||
<StyledAlert alertvariant={alertvariant} ref={ref}>
|
||||
<StyledContainer>
|
||||
<Typography variant='body1'>{message}</Typography>
|
||||
<IconButton onClick={closeSnackBarHandler} color='inherit'>
|
||||
<CloseIcon color='inherit' />
|
||||
</IconButton>
|
||||
</StyledContainer>
|
||||
</StyledAlert>
|
||||
)
|
||||
},
|
||||
)
|
9
src/components/Notification/const.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { VariantType } from 'notistack'
|
||||
|
||||
type Variant = Exclude<VariantType, 'default' | 'info'>
|
||||
|
||||
export const BORDER_STYLES: Record<Variant, string> = {
|
||||
error: '#b90e0a',
|
||||
success: '#32cd32',
|
||||
warning: '#FF9500',
|
||||
}
|
46
src/components/Notification/styled.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Alert, Box, styled } from '@mui/material'
|
||||
import { StyledAlertProps } from './types'
|
||||
import { BORDER_STYLES } from './const'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
export const StyledAlert = styled(
|
||||
forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => (
|
||||
<Alert {...props} ref={ref} icon={false} />
|
||||
)),
|
||||
)(({ alertvariant }) => ({
|
||||
width: '100%',
|
||||
maxHeight: 56,
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#FFF',
|
||||
borderRadius: 4,
|
||||
border: `solid ${BORDER_STYLES[alertvariant]} 1px`,
|
||||
color: BORDER_STYLES[alertvariant],
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
'& .MuiAlert-message': {
|
||||
display: 'flex',
|
||||
minWidth: '100%',
|
||||
justifyContent: 'space-between',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
export const StyledContainer = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
width: '100%',
|
||||
'& > .MuiTypography-root': {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
wordBreak: 'break-word',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontWeight: 500,
|
||||
},
|
||||
}))
|
11
src/components/Notification/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AlertProps } from '@mui/material'
|
||||
import { SnackbarKey, VariantType } from 'notistack'
|
||||
|
||||
export type StyledAlertProps = Omit<AlertProps, 'id'> & {
|
||||
alertvariant: Exclude<VariantType, 'default' | 'info'>
|
||||
}
|
||||
|
||||
export type NotificationProps = {
|
||||
message: string
|
||||
id: SnackbarKey
|
||||
} & StyledAlertProps
|
33
src/hooks/useEnqueueSnackbar.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
useSnackbar as useDefaultSnackbar,
|
||||
OptionsObject,
|
||||
VariantType,
|
||||
} from 'notistack'
|
||||
import { Notification } from '../components/Notification/Notification'
|
||||
|
||||
export const useEnqueueSnackbar = () => {
|
||||
const { enqueueSnackbar } = useDefaultSnackbar()
|
||||
|
||||
const showSnackbar = (
|
||||
message: string,
|
||||
variant: Exclude<VariantType, 'default' | 'info'> = 'success',
|
||||
) => {
|
||||
enqueueSnackbar(message, {
|
||||
anchorOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
},
|
||||
content: (id) => {
|
||||
return (
|
||||
<Notification
|
||||
id={id}
|
||||
message={message}
|
||||
alertvariant={variant}
|
||||
/>
|
||||
)
|
||||
},
|
||||
} as OptionsObject)
|
||||
}
|
||||
|
||||
return showSnackbar
|
||||
}
|
@@ -1,8 +1,12 @@
|
||||
body {
|
||||
* {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
|
||||
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -11,3 +15,9 @@ code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
@@ -1,18 +1,32 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import { swicRegister } from './swic';
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
import reportWebVitals from './reportWebVitals'
|
||||
import { swicRegister } from './modules/swic'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Provider } from 'react-redux'
|
||||
import { persistor, store } from './store'
|
||||
import ThemeProvider from './modules/theme/ThemeProvider'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
import { SnackbarProvider } from 'notistack'
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<ThemeProvider>
|
||||
<SnackbarProvider maxSnack={3} autoHideDuration={3000}>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
// If you want your app to work offline and load faster, you can change
|
||||
// unregister() to register() below. Note this comes with some pitfalls.
|
||||
@@ -22,4 +36,4 @@ swicRegister()
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
reportWebVitals()
|
||||
|
20
src/layout/Header/Header.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Toolbar } from '@mui/material'
|
||||
|
||||
import { AppLogo } from '../../assets'
|
||||
import { StyledAppBar, StyledAppName } from './styled'
|
||||
import { Menu } from './components/Menu'
|
||||
|
||||
export const Header = () => {
|
||||
return (
|
||||
<StyledAppBar position='static'>
|
||||
<Toolbar>
|
||||
<StyledAppName>
|
||||
<AppLogo />
|
||||
<span>Nsec.app</span>
|
||||
</StyledAppName>
|
||||
|
||||
<Menu />
|
||||
</Toolbar>
|
||||
</StyledAppBar>
|
||||
)
|
||||
}
|
94
src/layout/Header/components/Menu.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
IconButton,
|
||||
IconButtonProps,
|
||||
ListItemIcon,
|
||||
MenuItem,
|
||||
MenuItemProps,
|
||||
Menu as MuiMenu,
|
||||
Typography,
|
||||
styled,
|
||||
} from '@mui/material'
|
||||
import MenuIcon from '@mui/icons-material/Menu'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode'
|
||||
import LightModeIcon from '@mui/icons-material/LightMode'
|
||||
import LoginIcon from '@mui/icons-material/Login'
|
||||
import { setThemeMode } from '@/store/reducers/ui.slice'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
const renderMenuItem = (
|
||||
Icon: ReactNode,
|
||||
handler: () => void,
|
||||
title: string | ReactNode,
|
||||
) => {
|
||||
return (
|
||||
<StyledMenuItem onClick={handler}>
|
||||
<ListItemIcon>{Icon}</ListItemIcon>
|
||||
<Typography fontWeight={500} variant='body2' noWrap>
|
||||
{title}
|
||||
</Typography>
|
||||
</StyledMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export const Menu = () => {
|
||||
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
||||
const dispatch = useAppDispatch()
|
||||
const navigate = useNavigate()
|
||||
const isDarkMode = themeMode === 'dark'
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
|
||||
const open = Boolean(anchorEl)
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
}
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
const handleChangeMode = () => {
|
||||
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
|
||||
}
|
||||
const handleNavigateToAuth = () => {
|
||||
navigate('/sign-up')
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const themeIcon = isDarkMode ? (
|
||||
<DarkModeIcon htmlColor='#fff' />
|
||||
) : (
|
||||
<LightModeIcon htmlColor='#feb94a' />
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<BurgerButton onClick={handleClick} />
|
||||
<MuiMenu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
||||
{renderMenuItem(<LoginIcon />, handleNavigateToAuth, 'Sign up')}
|
||||
{renderMenuItem(themeIcon, handleChangeMode, 'Change theme')}
|
||||
</MuiMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const BurgerButton = styled((props: IconButtonProps) => (
|
||||
<IconButton {...props}>
|
||||
<MenuIcon color='inherit' />
|
||||
</IconButton>
|
||||
))(({ theme }) => {
|
||||
const isDark = theme.palette.mode === 'dark'
|
||||
return {
|
||||
borderRadius: '1rem',
|
||||
background: isDark ? '#333333A8' : 'transparent',
|
||||
color: isDark ? '#FFFFFFA8' : 'initial',
|
||||
}
|
||||
})
|
||||
|
||||
const StyledMenuItem = styled((props: MenuItemProps) => (
|
||||
<MenuItem {...props} />
|
||||
))(() => ({
|
||||
padding: '0.5rem 1rem',
|
||||
}))
|
27
src/layout/Header/styled.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { AppBar, Typography, TypographyProps, styled } from '@mui/material'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export const StyledAppBar = styled(AppBar)(({ theme }) => {
|
||||
return {
|
||||
background: 'transparent',
|
||||
color: theme.palette.primary.main,
|
||||
boxShadow: 'none',
|
||||
borderBottom: '1.4px solid ' + theme.palette.primary.main,
|
||||
marginBottom: '1rem',
|
||||
}
|
||||
})
|
||||
|
||||
export const StyledAppName = styled((props: TypographyProps) => (
|
||||
<Typography component={Link} to={'/'} flexGrow={1} {...props} />
|
||||
))(() => ({
|
||||
'&:not(:hover)': {
|
||||
textDecoration: 'initial',
|
||||
},
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
lineHeight: '22.4px',
|
||||
}))
|
27
src/layout/Layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { FC } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Header } from './Header/Header'
|
||||
import { Container, ContainerProps, styled } from '@mui/material'
|
||||
|
||||
export const Layout: FC = () => {
|
||||
return (
|
||||
<StyledContainer maxWidth='md'>
|
||||
<Header />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledContainer = styled((props: ContainerProps) => (
|
||||
<Container maxWidth='sm' {...props} />
|
||||
))({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingBottom: '1rem',
|
||||
'& > main': {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||
<g fill="#61DAFB">
|
||||
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||
<path d="M520.5 78.1z"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 2.6 KiB |
833
src/modules/backend.ts
Normal file
@@ -0,0 +1,833 @@
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'
|
||||
import { dbi, DbKey, DbPending, DbPerm } from './db'
|
||||
import { Keys } from './keys'
|
||||
import NDK, {
|
||||
IEventHandlingStrategy,
|
||||
NDKEvent,
|
||||
NDKNip46Backend,
|
||||
NDKPrivateKeySigner,
|
||||
NDKSigner,
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS } from '../utils/consts'
|
||||
import { Nip04 } from './nip04'
|
||||
//import { PrivateKeySigner } from './signer'
|
||||
|
||||
//const PERF_TEST = false
|
||||
|
||||
export interface KeyInfo {
|
||||
npub: string
|
||||
nip05?: string
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
interface Key {
|
||||
npub: string
|
||||
ndk: NDK
|
||||
backoff: number
|
||||
signer: NDKSigner
|
||||
backend: NDKNip46Backend
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
req: DbPending
|
||||
cb: (allow: boolean, remember: boolean) => void
|
||||
}
|
||||
|
||||
interface IAllowCallbackParams {
|
||||
npub: string
|
||||
id: string
|
||||
method: string
|
||||
remotePubkey: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
params?: any
|
||||
}
|
||||
|
||||
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
|
||||
private privkey: string
|
||||
private nip04 = new Nip04()
|
||||
|
||||
constructor(privkey: string) {
|
||||
this.privkey = privkey
|
||||
}
|
||||
|
||||
private async getKey(
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
recipientPubkey: string,
|
||||
) {
|
||||
if (
|
||||
!(await backend.pubkeyAllowed({
|
||||
id,
|
||||
pubkey: remotePubkey,
|
||||
// @ts-ignore
|
||||
method: 'get_nip04_key',
|
||||
params: recipientPubkey,
|
||||
}))
|
||||
) {
|
||||
backend.debug(`get_nip04_key request from ${remotePubkey} rejected`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Buffer.from(
|
||||
this.nip04.createKey(this.privkey, recipientPubkey),
|
||||
).toString('hex')
|
||||
}
|
||||
|
||||
async handle(
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
params: string[],
|
||||
) {
|
||||
const [recipientPubkey] = params
|
||||
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
|
||||
}
|
||||
}
|
||||
|
||||
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
|
||||
readonly npub: string
|
||||
readonly method: string
|
||||
private body: IEventHandlingStrategy
|
||||
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
||||
|
||||
constructor(
|
||||
npub: string,
|
||||
method: string,
|
||||
body: IEventHandlingStrategy,
|
||||
allowCb: (params: IAllowCallbackParams) => Promise<boolean>,
|
||||
) {
|
||||
this.npub = npub
|
||||
this.method = method
|
||||
this.body = body
|
||||
this.allowCb = allowCb
|
||||
}
|
||||
|
||||
async handle(
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
params: string[],
|
||||
): Promise<string | undefined> {
|
||||
console.log(Date.now(), 'handle', {
|
||||
method: this.method,
|
||||
id,
|
||||
remotePubkey,
|
||||
params,
|
||||
})
|
||||
const allow = await this.allowCb({
|
||||
npub: this.npub,
|
||||
id,
|
||||
method: this.method,
|
||||
remotePubkey,
|
||||
params,
|
||||
})
|
||||
if (!allow) return undefined
|
||||
return this.body.handle(backend, id, remotePubkey, params).then((r) => {
|
||||
console.log(
|
||||
Date.now(),
|
||||
'req',
|
||||
id,
|
||||
'method',
|
||||
this.method,
|
||||
'result',
|
||||
r,
|
||||
)
|
||||
return r
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class NoauthBackend {
|
||||
readonly swg: ServiceWorkerGlobalScope
|
||||
private keysModule: Keys
|
||||
private enckeys: DbKey[] = []
|
||||
private keys: Key[] = []
|
||||
private perms: DbPerm[] = []
|
||||
private doneReqIds: string[] = []
|
||||
private confirmBuffer: Pending[] = []
|
||||
private accessBuffer: DbPending[] = []
|
||||
private notifCallback: (() => void) | null = null
|
||||
|
||||
public constructor(swg: ServiceWorkerGlobalScope) {
|
||||
this.swg = swg
|
||||
this.keysModule = new Keys(swg.crypto.subtle)
|
||||
|
||||
const self = this
|
||||
swg.addEventListener('activate', (event) => {
|
||||
console.log('activate')
|
||||
})
|
||||
|
||||
swg.addEventListener('install', (event) => {
|
||||
console.log('install')
|
||||
})
|
||||
|
||||
swg.addEventListener('push', (event) => {
|
||||
console.log('got push', event)
|
||||
self.onPush(event)
|
||||
event.waitUntil(
|
||||
new Promise((ok: any) => {
|
||||
self.setNotifCallback(ok)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
swg.addEventListener('message', (event) => {
|
||||
self.onMessage(event)
|
||||
})
|
||||
|
||||
swg.addEventListener(
|
||||
'notificationclick',
|
||||
(event) => {
|
||||
event.notification.close()
|
||||
if (event.action.startsWith('allow:')) {
|
||||
self.confirm(event.action.split(':')[1], true, false)
|
||||
} else if (event.action.startsWith('allow-remember:')) {
|
||||
self.confirm(event.action.split(':')[1], true, true)
|
||||
} else if (event.action.startsWith('disallow:')) {
|
||||
self.confirm(event.action.split(':')[1], false, false)
|
||||
} else {
|
||||
event.waitUntil(
|
||||
self.swg.clients
|
||||
.matchAll({ type: 'window' })
|
||||
.then((clientList) => {
|
||||
console.log('clients', clientList.length)
|
||||
for (const client of clientList) {
|
||||
console.log('client', client.url)
|
||||
if (
|
||||
new URL(client.url).pathname === '/' &&
|
||||
'focus' in client
|
||||
)
|
||||
return client.focus()
|
||||
}
|
||||
// if (self.swg.clients.openWindow)
|
||||
// return self.swg.clients.openWindow("/");
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
false, // ???
|
||||
)
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.enckeys = await dbi.listKeys()
|
||||
console.log('started encKeys', this.listKeys())
|
||||
this.perms = await dbi.listPerms()
|
||||
console.log('started perms', this.perms)
|
||||
|
||||
const sub = await this.swg.registration.pushManager.getSubscription()
|
||||
|
||||
for (const k of this.enckeys) {
|
||||
await this.unlock(k.npub)
|
||||
|
||||
// ensure we're subscribed on the server
|
||||
if (sub) await this.sendSubscriptionToServer(k.npub, sub)
|
||||
}
|
||||
}
|
||||
|
||||
public setNotifCallback(cb: () => void) {
|
||||
if (this.notifCallback) {
|
||||
this.notify()
|
||||
}
|
||||
this.notifCallback = cb
|
||||
}
|
||||
|
||||
public listKeys(): KeyInfo[] {
|
||||
return this.enckeys.map<KeyInfo>((k) => this.keyInfo(k))
|
||||
}
|
||||
|
||||
public isLocked(npub: string): boolean {
|
||||
return !this.keys.find((k) => k.npub === npub)
|
||||
}
|
||||
|
||||
public hasKey(npub: string): boolean {
|
||||
return !!this.enckeys.find((k) => k.npub === npub)
|
||||
}
|
||||
|
||||
private async sha256(s: string) {
|
||||
return Buffer.from(
|
||||
await this.swg.crypto.subtle.digest('SHA-256', Buffer.from(s)),
|
||||
).toString('hex')
|
||||
}
|
||||
|
||||
private async sendPost({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
}: {
|
||||
url: string
|
||||
method: string
|
||||
headers: any
|
||||
body: string
|
||||
}) {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (r.status !== 200 && r.status !== 201) {
|
||||
console.log('Fetch error', url, method, r.status)
|
||||
throw new Error('Failed to fetch' + url)
|
||||
}
|
||||
|
||||
return await r.json()
|
||||
}
|
||||
|
||||
private async sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method = 'GET',
|
||||
body = '',
|
||||
}: {
|
||||
npub: string
|
||||
url: string
|
||||
method: string
|
||||
body: string
|
||||
}) {
|
||||
const { data: pubkey } = nip19.decode(npub)
|
||||
|
||||
const key = this.keys.find((k) => k.npub === npub)
|
||||
if (!key) throw new Error('Unknown key')
|
||||
|
||||
const authEvent = new NDKEvent(key.ndk, {
|
||||
pubkey: pubkey as string,
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method],
|
||||
],
|
||||
})
|
||||
if (body) authEvent.tags.push(['payload', await this.sha256(body)])
|
||||
|
||||
authEvent.sig = await authEvent.sign(key.signer)
|
||||
|
||||
const auth = this.swg.btoa(JSON.stringify(authEvent.rawEvent()))
|
||||
|
||||
return await this.sendPost({
|
||||
url,
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Nostr ${auth}`,
|
||||
},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
private async sendSubscriptionToServer(
|
||||
npub: string,
|
||||
pushSubscription: PushSubscription,
|
||||
) {
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
relays: NIP46_RELAYS,
|
||||
pushSubscription,
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/subscribe`
|
||||
|
||||
return this.sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
})
|
||||
}
|
||||
private async sendKeyToServer(npub: string, enckey: string, pwh: string) {
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
data: enckey,
|
||||
pwh,
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/put`
|
||||
|
||||
return this.sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
private async fetchKeyFromServer(npub: string, pwh: string) {
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
pwh,
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/get`
|
||||
|
||||
return await this.sendPost({
|
||||
url,
|
||||
method,
|
||||
headers: {},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
private notify() {
|
||||
// FIXME collect info from accessBuffer and confirmBuffer
|
||||
// and update the notifications
|
||||
|
||||
for (const r of this.confirmBuffer) {
|
||||
const text = `Confirm "${r.req.method}" by "${r.req.appNpub}"`
|
||||
this.swg.registration.showNotification('Signer access', {
|
||||
body: text,
|
||||
tag: 'confirm-' + r.req.appNpub,
|
||||
actions: [
|
||||
{
|
||||
action: 'allow:' + r.req.id,
|
||||
title: 'Yes',
|
||||
},
|
||||
{
|
||||
action: 'disallow:' + r.req.id,
|
||||
title: 'No',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (this.notifCallback) this.notifCallback()
|
||||
}
|
||||
|
||||
private keyInfo(k: DbKey): KeyInfo {
|
||||
return {
|
||||
npub: k.npub,
|
||||
nip05: k.nip05,
|
||||
locked: this.isLocked(k.npub),
|
||||
}
|
||||
}
|
||||
|
||||
private async generateGoodKey(): Promise<string> {
|
||||
return generatePrivateKey()
|
||||
}
|
||||
|
||||
public async addKey(nsec?: string): Promise<KeyInfo> {
|
||||
let sk = ''
|
||||
if (nsec) {
|
||||
const { type, data } = nip19.decode(nsec)
|
||||
if (type !== 'nsec') throw new Error('Bad nsec')
|
||||
sk = data
|
||||
} else {
|
||||
sk = await this.generateGoodKey()
|
||||
}
|
||||
const pubkey = getPublicKey(sk)
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
const localKey = await this.keysModule.generateLocalKey()
|
||||
const enckey = await this.keysModule.encryptKeyLocal(sk, localKey)
|
||||
// @ts-ignore
|
||||
const dbKey: DbKey = { npub, enckey, localKey }
|
||||
await dbi.addKey(dbKey)
|
||||
this.enckeys.push(dbKey)
|
||||
await this.startKey({ npub, sk })
|
||||
|
||||
const sub = await this.swg.registration.pushManager.getSubscription()
|
||||
if (sub) await this.sendSubscriptionToServer(npub, sub)
|
||||
|
||||
return this.keyInfo(dbKey)
|
||||
}
|
||||
|
||||
private getPerm(req: DbPending): string {
|
||||
return (
|
||||
this.perms.find(
|
||||
(p) =>
|
||||
p.npub === req.npub &&
|
||||
p.appNpub === req.appNpub &&
|
||||
p.perm === req.method,
|
||||
)?.value || ''
|
||||
)
|
||||
}
|
||||
|
||||
private async allowPermitCallback({
|
||||
npub,
|
||||
id,
|
||||
method,
|
||||
remotePubkey,
|
||||
params,
|
||||
}: IAllowCallbackParams): Promise<boolean> {
|
||||
// same reqs usually come on reconnects
|
||||
if (this.doneReqIds.includes(id)) {
|
||||
console.log('request already done', id)
|
||||
// FIXME maybe repeat the reply, but without the Notification?
|
||||
return false
|
||||
}
|
||||
|
||||
const appNpub = nip19.npubEncode(remotePubkey)
|
||||
const req: DbPending = {
|
||||
id,
|
||||
npub,
|
||||
appNpub,
|
||||
method,
|
||||
params: JSON.stringify(params),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
const self = this
|
||||
return new Promise(async (ok) => {
|
||||
// called when it's decided whether to allow this or not
|
||||
const onAllow = async (
|
||||
manual: boolean,
|
||||
allow: boolean,
|
||||
remember: boolean,
|
||||
) => {
|
||||
// confirm
|
||||
console.log(
|
||||
Date.now(),
|
||||
allow ? 'allowed' : 'disallowed',
|
||||
npub,
|
||||
method,
|
||||
params,
|
||||
)
|
||||
if (manual) {
|
||||
await dbi.confirmPending(id, allow)
|
||||
|
||||
if (!(await dbi.getApp(req.appNpub))) {
|
||||
await dbi.addApp({
|
||||
appNpub: req.appNpub,
|
||||
npub: req.npub,
|
||||
timestamp: Date.now(),
|
||||
name: '',
|
||||
icon: '',
|
||||
url: '',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// just send to db w/o waiting for it
|
||||
// if (!PERF_TEST)
|
||||
dbi.addConfirmed({
|
||||
...req,
|
||||
allowed: allow,
|
||||
})
|
||||
}
|
||||
|
||||
// for notifications
|
||||
self.accessBuffer.push(req)
|
||||
|
||||
// clear from pending
|
||||
const index = self.confirmBuffer.findIndex(
|
||||
(r) => r.req.id === id,
|
||||
)
|
||||
if (index >= 0) self.confirmBuffer.splice(index, 1)
|
||||
|
||||
if (remember) {
|
||||
await dbi.addPerm({
|
||||
id: req.id,
|
||||
npub: req.npub,
|
||||
appNpub: req.appNpub,
|
||||
perm: method,
|
||||
value: allow ? '1' : '0',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
this.perms = await dbi.listPerms()
|
||||
|
||||
const otherReqs = self.confirmBuffer.filter(
|
||||
(r) => r.req.appNpub === req.appNpub,
|
||||
)
|
||||
for (const r of otherReqs) {
|
||||
if (r.req.method === req.method) {
|
||||
r.cb(allow, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notify UI that it was confirmed
|
||||
// if (!PERF_TEST)
|
||||
this.updateUI()
|
||||
|
||||
// return to let nip46 flow proceed
|
||||
ok(allow)
|
||||
}
|
||||
|
||||
// check perms
|
||||
const perm = this.getPerm(req)
|
||||
console.log(Date.now(), 'perm', req.id, perm)
|
||||
|
||||
// have perm?
|
||||
if (perm) {
|
||||
// reply immediately
|
||||
onAllow(false, perm === '1', false)
|
||||
} else {
|
||||
// put pending req to db
|
||||
await dbi.addPending(req)
|
||||
|
||||
// need manual confirmation
|
||||
console.log('need confirm', req)
|
||||
|
||||
// put to a list of pending requests
|
||||
this.confirmBuffer.push({
|
||||
req,
|
||||
cb: (allow, remember) => onAllow(true, allow, remember),
|
||||
})
|
||||
|
||||
// show notifs
|
||||
this.notify()
|
||||
|
||||
// notify main thread to ask for user concent
|
||||
// FIXME show a 'confirm' notification?
|
||||
this.updateUI()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async startKey({
|
||||
npub,
|
||||
sk,
|
||||
backoff = 1000,
|
||||
}: {
|
||||
npub: string
|
||||
sk: string
|
||||
backoff?: number
|
||||
}) {
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: NIP46_RELAYS,
|
||||
})
|
||||
|
||||
// init relay objects but dont wait until we connect
|
||||
ndk.connect()
|
||||
|
||||
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
|
||||
const backend = new NDKNip46Backend(ndk, sk, () =>
|
||||
Promise.resolve(true),
|
||||
)
|
||||
this.keys.push({ npub, backend, signer, ndk, backoff })
|
||||
|
||||
// new method
|
||||
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
|
||||
|
||||
// assign our own permission callback
|
||||
for (const method in backend.handlers) {
|
||||
backend.handlers[method] = new EventHandlingStrategyWrapper(
|
||||
npub,
|
||||
method,
|
||||
backend.handlers[method],
|
||||
this.allowPermitCallback.bind(this),
|
||||
)
|
||||
}
|
||||
|
||||
// start
|
||||
backend.start()
|
||||
console.log('started', npub)
|
||||
|
||||
// backoff reset on successfull connection
|
||||
const self = this
|
||||
const onConnect = () => {
|
||||
// reset backoff
|
||||
const key = self.keys.find((k) => k.npub === npub)
|
||||
if (key) key.backoff = 0
|
||||
console.log('reset backoff for', npub)
|
||||
}
|
||||
|
||||
// reconnect handling
|
||||
let reconnected = false
|
||||
const onDisconnect = () => {
|
||||
if (reconnected) return
|
||||
if (ndk.pool.connectedRelays().length > 0) return
|
||||
reconnected = true
|
||||
console.log(new Date(), 'all relays are down for key', npub)
|
||||
|
||||
// run full restart after a pause
|
||||
const bo = self.keys.find((k) => k.npub === npub)?.backoff || 1000
|
||||
setTimeout(() => {
|
||||
console.log(
|
||||
new Date(),
|
||||
'reconnect relays for key',
|
||||
npub,
|
||||
'backoff',
|
||||
bo,
|
||||
)
|
||||
// @ts-ignore
|
||||
for (const r of ndk.pool.relays.values()) r.disconnect()
|
||||
// make sure it no longer activates
|
||||
backend.handlers = {}
|
||||
|
||||
self.keys = self.keys.filter((k) => k.npub !== npub)
|
||||
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
|
||||
}, bo)
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
for (const r of ndk.pool.relays.values()) {
|
||||
r.on('connect', onConnect)
|
||||
r.on('disconnect', onDisconnect)
|
||||
}
|
||||
}
|
||||
|
||||
public async unlock(npub: string) {
|
||||
console.log('unlocking', npub)
|
||||
if (!this.isLocked(npub))
|
||||
throw new Error(`Key ${npub} already unlocked`)
|
||||
const info = this.enckeys.find((k) => k.npub === npub)
|
||||
if (!info) throw new Error(`Key ${npub} not found`)
|
||||
const { type } = nip19.decode(npub)
|
||||
if (type !== 'npub') throw new Error(`Invalid npub ${npub}`)
|
||||
const sk = await this.keysModule.decryptKeyLocal({
|
||||
enckey: info.enckey,
|
||||
// @ts-ignore
|
||||
localKey: info.localKey,
|
||||
})
|
||||
await this.startKey({ npub, sk })
|
||||
}
|
||||
|
||||
private async generateKey() {
|
||||
const k = await this.addKey()
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
|
||||
private async importKey(nsec: string) {
|
||||
const k = await this.addKey(nsec)
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
|
||||
private async saveKey(npub: string, passphrase: string) {
|
||||
const info = this.enckeys.find((k) => k.npub === npub)
|
||||
if (!info) throw new Error(`Key ${npub} not found`)
|
||||
const sk = await this.keysModule.decryptKeyLocal({
|
||||
enckey: info.enckey,
|
||||
// @ts-ignore
|
||||
localKey: info.localKey,
|
||||
})
|
||||
const { enckey, pwh } = await this.keysModule.encryptKeyPass({
|
||||
key: sk,
|
||||
passphrase,
|
||||
})
|
||||
await this.sendKeyToServer(npub, enckey, pwh)
|
||||
}
|
||||
|
||||
private async fetchKey(npub: string, passphrase: string) {
|
||||
const { type, data: pubkey } = nip19.decode(npub)
|
||||
if (type !== 'npub') throw new Error(`Invalid npub ${npub}`)
|
||||
const { pwh } = await this.keysModule.generatePassKey(
|
||||
pubkey,
|
||||
passphrase,
|
||||
)
|
||||
const { data: enckey } = await this.fetchKeyFromServer(npub, pwh)
|
||||
|
||||
// key already exists?
|
||||
const key = this.enckeys.find((k) => k.npub === npub)
|
||||
if (key) return this.keyInfo(key)
|
||||
|
||||
// add new key
|
||||
const nsec = await this.keysModule.decryptKeyPass({
|
||||
pubkey,
|
||||
enckey,
|
||||
passphrase,
|
||||
})
|
||||
const k = await this.addKey(nsec)
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
|
||||
private async confirm(id: string, allow: boolean, remember: boolean) {
|
||||
const req = this.confirmBuffer.find((r) => r.req.id === id)
|
||||
if (!req) {
|
||||
console.log('req ', id, 'not found')
|
||||
|
||||
await dbi.removePending(id)
|
||||
this.updateUI()
|
||||
} else {
|
||||
console.log('confirming', id, allow, remember)
|
||||
req.cb(allow, remember)
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteApp(appNpub: string) {
|
||||
this.perms = this.perms.filter((p) => p.appNpub !== appNpub)
|
||||
await dbi.removeApp(appNpub)
|
||||
await dbi.removeAppPerms(appNpub)
|
||||
this.updateUI()
|
||||
}
|
||||
|
||||
private async deletePerm(id: string) {
|
||||
this.perms = this.perms.filter((p) => p.id !== id)
|
||||
await dbi.removePerm(id)
|
||||
this.updateUI()
|
||||
}
|
||||
|
||||
private async enablePush(): Promise<boolean> {
|
||||
const options = {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: WEB_PUSH_PUBKEY,
|
||||
}
|
||||
|
||||
const pushSubscription =
|
||||
await this.swg.registration.pushManager.subscribe(options)
|
||||
console.log('push endpoint', JSON.stringify(pushSubscription))
|
||||
|
||||
if (!pushSubscription) {
|
||||
console.log('failed to enable push subscription')
|
||||
return false
|
||||
}
|
||||
|
||||
// subscribe to all pubkeys
|
||||
for (const k of this.keys) {
|
||||
await this.sendSubscriptionToServer(k.npub, pushSubscription)
|
||||
}
|
||||
console.log('push enabled')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
public async onMessage(event: any) {
|
||||
const { id, method, args } = event.data
|
||||
try {
|
||||
//console.log("UI message", id, method, args)
|
||||
let result = undefined
|
||||
if (method === 'generateKey') {
|
||||
result = await this.generateKey()
|
||||
} else if (method === 'importKey') {
|
||||
result = await this.importKey(args[0])
|
||||
} else if (method === 'saveKey') {
|
||||
result = await this.saveKey(args[0], args[1])
|
||||
} else if (method === 'fetchKey') {
|
||||
result = await this.fetchKey(args[0], args[1])
|
||||
} else if (method === 'confirm') {
|
||||
result = await this.confirm(args[0], args[1], args[2])
|
||||
} else if (method === 'deleteApp') {
|
||||
result = await this.deleteApp(args[0])
|
||||
} else if (method === 'deletePerm') {
|
||||
result = await this.deletePerm(args[0])
|
||||
} else if (method === 'enablePush') {
|
||||
result = await this.enablePush()
|
||||
} else {
|
||||
console.log('unknown method from UI ', method)
|
||||
}
|
||||
event.source.postMessage({
|
||||
id,
|
||||
result,
|
||||
})
|
||||
} catch (e: any) {
|
||||
event.source.postMessage({
|
||||
id,
|
||||
error: e.toString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async updateUI() {
|
||||
const clients = await this.swg.clients.matchAll()
|
||||
console.log('updateUI clients', clients.length)
|
||||
for (const client of clients) {
|
||||
client.postMessage({})
|
||||
}
|
||||
}
|
||||
|
||||
public async onPush(event: any) {
|
||||
console.log('push', { data: event.data })
|
||||
// noop - we just need browser to launch this worker
|
||||
// FIXME use event.waitUntil and and unblock after we
|
||||
// show a notification
|
||||
}
|
||||
}
|
17
src/modules/nostr.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import NDK from '@nostr-dev-kit/ndk'
|
||||
|
||||
export const ndk = new NDK({
|
||||
explicitRelayUrls: ['wss://relay.nostr.band/all'],
|
||||
})
|
||||
|
||||
export async function fetchProfile(pubkey: string) {
|
||||
const event = await ndk.fetchEvent({ kinds: [0], authors: [pubkey] })
|
||||
|
||||
if (event) {
|
||||
return {
|
||||
...event,
|
||||
info: JSON.parse(event.content),
|
||||
}
|
||||
}
|
||||
return event
|
||||
}
|
74
src/modules/signer.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { UnsignedEvent } from 'nostr-tools'
|
||||
import { generatePrivateKey, getPublicKey, getSignature } from 'nostr-tools'
|
||||
|
||||
import type { NostrEvent } from '@nostr-dev-kit/ndk' // "./ndk-dist";
|
||||
import { NDKUser } from '@nostr-dev-kit/ndk' // "./ndk-dist";
|
||||
import type { NDKSigner } from '@nostr-dev-kit/ndk' // "./ndk-dist";
|
||||
import { Nip04 } from './nip04'
|
||||
//import { decrypt, encrypt } from "./ende";
|
||||
|
||||
export class PrivateKeySigner implements NDKSigner {
|
||||
private _user: NDKUser | undefined
|
||||
privateKey?: string
|
||||
private nip04: Nip04
|
||||
|
||||
public constructor(privateKey?: string) {
|
||||
if (privateKey) {
|
||||
this.privateKey = privateKey
|
||||
this._user = new NDKUser({
|
||||
hexpubkey: getPublicKey(this.privateKey),
|
||||
})
|
||||
}
|
||||
this.nip04 = new Nip04()
|
||||
}
|
||||
|
||||
public static generate() {
|
||||
const privateKey = generatePrivateKey()
|
||||
return new PrivateKeySigner(privateKey)
|
||||
}
|
||||
|
||||
public async blockUntilReady(): Promise<NDKUser> {
|
||||
if (!this._user) {
|
||||
throw new Error('NDKUser not initialized')
|
||||
}
|
||||
return this._user
|
||||
}
|
||||
|
||||
public async user(): Promise<NDKUser> {
|
||||
await this.blockUntilReady()
|
||||
return this._user as NDKUser
|
||||
}
|
||||
|
||||
public async sign(event: NostrEvent): Promise<string> {
|
||||
if (!this.privateKey) {
|
||||
throw Error('Attempted to sign without a private key')
|
||||
}
|
||||
|
||||
return getSignature(event as UnsignedEvent, this.privateKey)
|
||||
}
|
||||
|
||||
public async encrypt(recipient: NDKUser, value: string): Promise<string> {
|
||||
if (!this.privateKey) {
|
||||
throw Error('Attempted to encrypt without a private key')
|
||||
}
|
||||
|
||||
const recipientHexPubKey = recipient.hexpubkey
|
||||
return await this.nip04.encrypt(
|
||||
this.privateKey,
|
||||
recipientHexPubKey,
|
||||
value,
|
||||
)
|
||||
// return await encrypt(recipientHexPubKey, value, this.privateKey);
|
||||
}
|
||||
|
||||
public async decrypt(sender: NDKUser, value: string): Promise<string> {
|
||||
if (!this.privateKey) {
|
||||
throw Error('Attempted to decrypt without a private key')
|
||||
}
|
||||
|
||||
const senderHexPubKey = sender.hexpubkey
|
||||
// console.log("nip04_decrypt", value)
|
||||
return await this.nip04.decrypt(this.privateKey, senderHexPubKey, value)
|
||||
// return await decrypt(this.privateKey, senderHexPubKey, value) as string;
|
||||
}
|
||||
}
|
72
src/modules/swic.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// service-worker client interface
|
||||
import * as serviceWorkerRegistration from '../serviceWorkerRegistration'
|
||||
|
||||
let swr: ServiceWorkerRegistration | null = null
|
||||
const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>()
|
||||
let nextReqId = 1
|
||||
let onRender: (() => void) | null = null
|
||||
|
||||
export async function swicRegister() {
|
||||
serviceWorkerRegistration.register({
|
||||
onSuccess(registration) {
|
||||
console.log('sw registered')
|
||||
swr = registration
|
||||
},
|
||||
onError(e) {
|
||||
console.log(`error ${e}`)
|
||||
|
||||
console.log(e, 'HISH')
|
||||
},
|
||||
})
|
||||
|
||||
navigator.serviceWorker.ready.then((r) => (swr = r))
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
onMessage((event as MessageEvent).data)
|
||||
})
|
||||
}
|
||||
|
||||
function onMessage(data: any) {
|
||||
const { id, result, error } = data
|
||||
console.log('SW message', id, result, error)
|
||||
|
||||
if (!id) {
|
||||
if (onRender) onRender()
|
||||
return
|
||||
}
|
||||
|
||||
const r = reqs.get(id)
|
||||
if (!r) {
|
||||
console.log('Unexpected message from service worker', data)
|
||||
return
|
||||
}
|
||||
|
||||
reqs.delete(id)
|
||||
if (error) r.rej(error)
|
||||
else r.ok(result)
|
||||
}
|
||||
|
||||
export async function swicCall(method: string, ...args: any[]) {
|
||||
const id = nextReqId
|
||||
nextReqId++
|
||||
|
||||
return new Promise((ok, rej) => {
|
||||
if (!swr || !swr.active) {
|
||||
rej(new Error('No active service worker'))
|
||||
return
|
||||
}
|
||||
|
||||
reqs.set(id, { ok, rej })
|
||||
const msg = {
|
||||
id,
|
||||
method,
|
||||
args: [...args],
|
||||
}
|
||||
//console.log("sending to SW", msg)
|
||||
swr.active.postMessage(msg)
|
||||
})
|
||||
}
|
||||
|
||||
export function swicOnRender(cb: () => void) {
|
||||
onRender = cb
|
||||
}
|
18
src/modules/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { FC, PropsWithChildren } from 'react'
|
||||
import { ThemeProvider as ThemeMuiProvider, CssBaseline } from '@mui/material'
|
||||
import { darkTheme, lightTheme } from './theme'
|
||||
import { useAppSelector } from '../../store/hooks/redux'
|
||||
|
||||
const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
||||
const isDarkMode = themeMode === 'dark'
|
||||
|
||||
return (
|
||||
<ThemeMuiProvider theme={isDarkMode ? darkTheme : lightTheme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeMuiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeProvider
|
90
src/modules/theme/theme.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createTheme, Theme } from '@mui/material'
|
||||
|
||||
// declare module '@mui/material/styles' {
|
||||
// interface Palette {
|
||||
// light: Palette['primary']
|
||||
// decorate: Palette['primary']
|
||||
// actionPrimary: Palette['primary']
|
||||
// textPrimaryDecorate: Palette['primary']
|
||||
// textSecondaryDecorate: Palette['primary']
|
||||
// }
|
||||
|
||||
// interface PaletteOptions {
|
||||
// light?: Palette['primary']
|
||||
// decorate?: Palette['primary']
|
||||
// actionPrimary?: Palette['primary']
|
||||
// textPrimaryDecorate?: Palette['primary']
|
||||
// textSecondaryDecorate?: Palette['primary']
|
||||
// }
|
||||
// }
|
||||
|
||||
const commonTheme: Theme = createTheme({
|
||||
typography: {
|
||||
fontFamily: ['Inter', 'sans-serif'].join(','),
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'initial',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const lightTheme: Theme = createTheme({
|
||||
...commonTheme,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#000000',
|
||||
},
|
||||
secondary: {
|
||||
main: '#9c27b0',
|
||||
light: '#00000029',
|
||||
dark: '#333333',
|
||||
},
|
||||
error: {
|
||||
main: '#f44336',
|
||||
},
|
||||
background: {
|
||||
default: '#f7f7f7',
|
||||
paper: '#f7f7f7',
|
||||
},
|
||||
text: {
|
||||
primary: '#333333',
|
||||
secondary: '#757575',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const darkTheme: Theme = createTheme({
|
||||
...commonTheme,
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
main: '#f48fb1',
|
||||
light: '#FFFFFF29',
|
||||
dark: '#333333A8',
|
||||
},
|
||||
error: {
|
||||
main: '#ef9a9a',
|
||||
},
|
||||
background: {
|
||||
default: '#121212',
|
||||
paper: '#28282B',
|
||||
},
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#b3b3b3',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log({ lightTheme, darkTheme })
|
||||
|
||||
export { lightTheme, darkTheme }
|
7
src/pages/App.Page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
const AppPage = () => {
|
||||
return <div>AppPage</div>
|
||||
}
|
||||
|
||||
export default AppPage
|
62
src/pages/AuthPage/Auth.Page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import {
|
||||
Button,
|
||||
Stack,
|
||||
InputAdornment,
|
||||
useMediaQuery,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { StyledAppLogo, StyledContent } from './styled'
|
||||
|
||||
const AuthPage = () => {
|
||||
const isMobile = useMediaQuery('(max-width:600px)')
|
||||
|
||||
const commonContent = (
|
||||
<>
|
||||
<Input
|
||||
label='Enter a Username'
|
||||
fullWidth
|
||||
placeholder='Username'
|
||||
helperText="Don't worry, username can be changed later."
|
||||
endAdornment={
|
||||
<InputAdornment position='end'>@nsec.app</InputAdornment>
|
||||
}
|
||||
/>
|
||||
<Button variant='contained'>Sign up</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderContent = () => {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<StyledContent>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
gap={'1rem'}
|
||||
alignItems={'center'}
|
||||
alignSelf={'flex-start'}
|
||||
>
|
||||
<StyledAppLogo />
|
||||
<Typography fontWeight={600} variant='h5'>
|
||||
Sign up
|
||||
</Typography>
|
||||
</Stack>
|
||||
{commonContent}
|
||||
</StyledContent>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Stack gap={'1rem'} alignItems={'center'}>
|
||||
{commonContent}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack height={'100%'} position={'relative'}>
|
||||
{renderContent()}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthPage
|
34
src/pages/AuthPage/styled.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { AppLogo } from '@/assets'
|
||||
import { Stack, styled, StackProps, Box } from '@mui/material'
|
||||
|
||||
export const StyledContent = styled((props: StackProps) => (
|
||||
<Stack {...props} gap={'1rem'} alignItems={'center'} />
|
||||
))(({ theme }) => {
|
||||
const isDark = theme.palette.mode === 'dark'
|
||||
|
||||
return {
|
||||
background: isDark ? '#333333A8' : '#dddddda8',
|
||||
position: 'absolute',
|
||||
bottom: '-1rem',
|
||||
left: '-1rem',
|
||||
width: 'calc(100% + 2rem)',
|
||||
height: '70%',
|
||||
borderTopLeftRadius: '2rem',
|
||||
borderTopRightRadius: '2rem',
|
||||
padding: '1rem',
|
||||
maxWidth: '50rem',
|
||||
margin: '0 auto',
|
||||
}
|
||||
})
|
||||
|
||||
export const StyledAppLogo = styled((props) => (
|
||||
<Box {...props}>
|
||||
<AppLogo />
|
||||
</Box>
|
||||
))({
|
||||
background: '#00000054',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
})
|
7
src/pages/Confirm.Page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
const ConfirmPage = () => {
|
||||
return <div>ConfirmPage</div>
|
||||
}
|
||||
|
||||
export default ConfirmPage
|
65
src/pages/HomePage/Home.Page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useRef } from 'react'
|
||||
import { useAppSelector } from '../../store/hooks/redux'
|
||||
import { selectKeys } from '../../store'
|
||||
import { ItemKey } from './components/ItemKey'
|
||||
import { Stack } from '@mui/material'
|
||||
import { call } from '../../utils/helpers'
|
||||
import { swicCall } from '../../modules/swic'
|
||||
import { SectionTitle } from '../../shared/SectionTitle/SectionTitle'
|
||||
import { useEnqueueSnackbar } from '../../hooks/useEnqueueSnackbar'
|
||||
|
||||
const HomePage = () => {
|
||||
const keys = useAppSelector(selectKeys)
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
const nsecInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
// eslint-disable-next-line
|
||||
async function importKey() {
|
||||
call(async () => {
|
||||
const nsec = nsecInputRef.current?.value
|
||||
if (!nsec) return
|
||||
await swicCall('importKey', nsec)
|
||||
notify('Key imported!', 'success')
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
async function generateKey() {
|
||||
call(async () => {
|
||||
const k: any = await swicCall('generateKey')
|
||||
notify(`New key ${k.npub}`, 'success')
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* <Box alignSelf={'center'} marginBottom={'1rem'}>
|
||||
<Button size='small' variant='contained' onClick={generateKey}>
|
||||
add key
|
||||
</Button>
|
||||
</Box>
|
||||
<Stack alignItems={'center'} gap='0.5rem'>
|
||||
<TextField
|
||||
variant='outlined'
|
||||
ref={nsecInputRef}
|
||||
placeholder='Enter nsec...'
|
||||
fullWidth
|
||||
size='small'
|
||||
/>
|
||||
<Button size='small' variant='contained' onClick={importKey}>
|
||||
import key (DANGER!)
|
||||
</Button>
|
||||
</Stack> */}
|
||||
|
||||
<SectionTitle>Keys:</SectionTitle>
|
||||
<Stack gap={'0.5rem'}>
|
||||
{keys.map((key) => (
|
||||
<ItemKey {...key} key={key.npub} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomePage
|
87
src/pages/HomePage/components/ItemKey.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { FC, useRef } from 'react'
|
||||
import { DbKey } from '../../../modules/db'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { NIP46_RELAYS } from '../../../utils/consts'
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Stack,
|
||||
StackProps,
|
||||
Typography,
|
||||
TypographyProps,
|
||||
styled,
|
||||
} from '@mui/material'
|
||||
import { call, log } from '../../../utils/helpers'
|
||||
import { swicCall } from '../../../modules/swic'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt'
|
||||
|
||||
type ItemKeyProps = DbKey
|
||||
|
||||
export const ItemKey: FC<ItemKeyProps> = ({ npub }) => {
|
||||
const navigate = useNavigate()
|
||||
const { data: pubkey } = nip19.decode(npub)
|
||||
const str = `bunker://${pubkey}?relay=${NIP46_RELAYS[0]}`
|
||||
|
||||
const passPhraseInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
// eslint-disable-next-line
|
||||
async function saveKey(npub: string) {
|
||||
call(async () => {
|
||||
const passphrase = passPhraseInputRef.current?.value
|
||||
await swicCall('saveKey', npub, passphrase)
|
||||
log('Key saved')
|
||||
})
|
||||
}
|
||||
|
||||
const handleNavigate = () => {
|
||||
navigate('/key/' + npub)
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledKeyContainer>
|
||||
<StyledText variant='body1'>{npub}</StyledText>
|
||||
<StyledText variant='body2' color={'#757575'}>
|
||||
{str}
|
||||
</StyledText>
|
||||
{/* <Stack direction={'row'} alignItems={'center'} gap={'0.5rem'}>
|
||||
<TextField
|
||||
ref={passPhraseInputRef}
|
||||
placeholder='save password'
|
||||
fullWidth
|
||||
size='small'
|
||||
/>
|
||||
<Button variant='contained' onClick={() => saveKey(npub)}>
|
||||
save
|
||||
</Button>
|
||||
</Stack> */}
|
||||
<Box alignSelf={'flex-end'}>
|
||||
<IconButton onClick={handleNavigate}>
|
||||
<ArrowRightAltIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</StyledKeyContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledKeyContainer = styled((props: StackProps) => (
|
||||
<Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} />
|
||||
))(({ theme }) => {
|
||||
return {
|
||||
boxShadow:
|
||||
theme.palette.mode === 'dark'
|
||||
? '4px 3px 10px 2px rgba(92, 92, 92, 0.2)'
|
||||
: '4px 3px 10px 3px rgba(0, 0, 0, 0.2)',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: '0.5rem 1rem',
|
||||
background: theme.palette.background.paper,
|
||||
}
|
||||
})
|
||||
|
||||
export const StyledText = styled((props: TypographyProps) => (
|
||||
<Typography {...props} />
|
||||
))({
|
||||
fontWeight: 500,
|
||||
width: '100%',
|
||||
wordBreak: 'break-all',
|
||||
})
|
155
src/pages/Key.Page.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { swicCall } from '../modules/swic'
|
||||
import { SectionTitle } from '../shared/SectionTitle/SectionTitle'
|
||||
import { useAppSelector } from '../store/hooks/redux'
|
||||
import { call } from '../utils/helpers'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { fetchProfile } from '../modules/nostr'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useEnqueueSnackbar } from '../hooks/useEnqueueSnackbar'
|
||||
import { Box, Stack, Typography } from '@mui/material'
|
||||
|
||||
const KeyPage = () => {
|
||||
const { apps, perms, pending } = useAppSelector((state) => state.content)
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
|
||||
const filteredApps = apps.filter((a) => a.npub === npub)
|
||||
const filteredPerms = perms.filter((p) => p.npub === npub)
|
||||
const filteredPendingRequests = pending.filter((p) => p.npub === npub)
|
||||
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
const [profile, setProfile] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const npubToken = npub.includes('#') ? npub.split('#')[0] : npub
|
||||
const { type, data: pubkey } = nip19.decode(npubToken)
|
||||
if (type !== 'npub') return undefined
|
||||
|
||||
const response = await fetchProfile(pubkey)
|
||||
console.log({ response, pubkey, npub, npubToken, profile })
|
||||
setProfile(response as any)
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// eslint-disable-next-line
|
||||
async function deleteApp(appNpub: string) {
|
||||
call(async () => {
|
||||
await swicCall('deleteApp', appNpub)
|
||||
notify('App deleted!', 'success')
|
||||
})
|
||||
}
|
||||
|
||||
async function deletePerm(id: string) {
|
||||
call(async () => {
|
||||
await swicCall('deletePerm', id)
|
||||
notify('Perm deleted!', 'success')
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmPending(
|
||||
id: string,
|
||||
allow: boolean,
|
||||
remember: boolean,
|
||||
) {
|
||||
call(async () => {
|
||||
await swicCall('confirm', id, allow, remember)
|
||||
console.log('confirmed', id, allow, remember)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={'1rem'}>
|
||||
<Box>
|
||||
<SectionTitle>Connected apps:</SectionTitle>
|
||||
{!filteredApps.length && (
|
||||
<Typography textAlign={'center'}>
|
||||
No connected apps
|
||||
</Typography>
|
||||
)}
|
||||
{filteredApps.map((a) => (
|
||||
<div key={a.npub} style={{ marginTop: '10px' }}>
|
||||
<Typography
|
||||
component={Link}
|
||||
to={`/key/${npub}/app/${a.appNpub}`}
|
||||
noWrap
|
||||
>
|
||||
App: {a.appNpub}
|
||||
{/* <button onClick={() => deleteApp(a.appNpub)}>
|
||||
x
|
||||
</button> */}
|
||||
</Typography>
|
||||
<SectionTitle>Permissions:</SectionTitle>
|
||||
{!filteredPerms.filter((p) => p.appNpub === a.appNpub)
|
||||
.length && (
|
||||
<Typography textAlign={'center'}>
|
||||
No permissions
|
||||
</Typography>
|
||||
)}
|
||||
{filteredPerms
|
||||
.filter((p) => p.appNpub === a.appNpub)
|
||||
.map((p) => (
|
||||
<div key={p.id}>
|
||||
{p.perm}: {p.value}
|
||||
<button onClick={() => deletePerm(p.id)}>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<hr />
|
||||
</div>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<SectionTitle>Pending requests:</SectionTitle>
|
||||
{!filteredPendingRequests.length && (
|
||||
<Typography textAlign={'center'}>
|
||||
No pending requests
|
||||
</Typography>
|
||||
)}
|
||||
{filteredPendingRequests.map((p) => (
|
||||
<div key={p.id}>
|
||||
<Typography
|
||||
component={Link}
|
||||
to={`/key/${npub}/${p.id}`}
|
||||
noWrap
|
||||
>
|
||||
Request details
|
||||
</Typography>
|
||||
APP: {p.appNpub} ({p.method})
|
||||
<button
|
||||
onClick={() => confirmPending(p.id, true, false)}
|
||||
>
|
||||
yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => confirmPending(p.id, false, false)}
|
||||
>
|
||||
no
|
||||
</button>
|
||||
<button
|
||||
onClick={() => confirmPending(p.id, true, true)}
|
||||
>
|
||||
yes all
|
||||
</button>
|
||||
<button
|
||||
onClick={() => confirmPending(p.id, false, true)}
|
||||
>
|
||||
no all
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default KeyPage
|
97
src/pages/Welcome.Page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useRef } from 'react'
|
||||
import { useAppSelector } from '../store/hooks/redux'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { swicCall } from '../modules/swic'
|
||||
import { Box, Button, Stack, TextField } from '@mui/material'
|
||||
import { useEnqueueSnackbar } from '../hooks/useEnqueueSnackbar'
|
||||
|
||||
const WelcomePage = () => {
|
||||
const keys = useAppSelector((state) => state.content.keys)
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
const isKeysExists = keys.length > 0
|
||||
|
||||
const nsecInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const npubInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
if (isKeysExists) return <Navigate to={'/home'} />
|
||||
|
||||
async function generateKey() {
|
||||
try {
|
||||
const k: any = await swicCall('generateKey')
|
||||
notify(`New key ${k.npub}`, 'success')
|
||||
} catch (error: any) {
|
||||
notify(error.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function importKey() {
|
||||
try {
|
||||
const nsec = nsecInputRef.current?.value
|
||||
if (!nsec) return
|
||||
await swicCall('importKey', nsec)
|
||||
} catch (error: any) {
|
||||
notify(error.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNewKey() {
|
||||
try {
|
||||
const npub = npubInputRef.current?.value
|
||||
const passphrase = passwordInputRef.current?.value
|
||||
console.log('fetch', npub, passphrase)
|
||||
const k: any = await swicCall('fetchKey', npub, passphrase)
|
||||
notify(`Fetched ${k.npub}`, 'success')
|
||||
} catch (error: any) {
|
||||
notify(error.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={'1.5rem'}>
|
||||
<Box alignSelf={'center'}>
|
||||
<Button size='small' variant='contained' onClick={generateKey}>
|
||||
generate key
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Stack alignItems={'center'} gap='0.5rem'>
|
||||
<TextField
|
||||
variant='outlined'
|
||||
ref={nsecInputRef}
|
||||
placeholder='Enter nsec...'
|
||||
fullWidth
|
||||
size='small'
|
||||
/>
|
||||
<Button size='small' variant='contained' onClick={importKey}>
|
||||
import key (DANGER!)
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack alignItems={'center'} gap='0.5rem'>
|
||||
<Stack width={'100%'} gap='0.5rem'>
|
||||
<TextField
|
||||
variant='outlined'
|
||||
ref={npubInputRef}
|
||||
placeholder='Enter npub...'
|
||||
fullWidth
|
||||
size='small'
|
||||
/>
|
||||
<TextField
|
||||
variant='outlined'
|
||||
ref={passwordInputRef}
|
||||
placeholder='Enter password'
|
||||
fullWidth
|
||||
size='small'
|
||||
/>
|
||||
</Stack>
|
||||
<Button size='small' variant='contained' onClick={fetchNewKey}>
|
||||
fetch key
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default WelcomePage
|
44
src/routes/AppRoutes.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { Route, Routes, Navigate } from 'react-router-dom'
|
||||
import HomePage from '../pages/HomePage/Home.Page'
|
||||
import WelcomePage from '../pages/Welcome.Page'
|
||||
import { Layout } from '../layout/Layout'
|
||||
import { CircularProgress, Stack } from '@mui/material'
|
||||
|
||||
const KeyPage = lazy(() => import('../pages/Key.Page'))
|
||||
const ConfirmPage = lazy(() => import('../pages/Confirm.Page'))
|
||||
const AppPage = lazy(() => import('../pages/App.Page'))
|
||||
const AuthPage = lazy(() => import('../pages/AuthPage/Auth.Page'))
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<Stack height={'100%'} justifyContent={'center'} alignItems={'center'}>
|
||||
<CircularProgress />
|
||||
</Stack>
|
||||
)
|
||||
|
||||
const AppRoutes = () => {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path='/' element={<Layout />}>
|
||||
<Route path='/' element={<Navigate to={'/welcome'} />} />
|
||||
<Route path='/welcome' element={<WelcomePage />} />
|
||||
<Route path='/home' element={<HomePage />} />
|
||||
<Route path='/sign-up' element={<AuthPage />} />
|
||||
<Route path='/key/:npub' element={<KeyPage />} />
|
||||
<Route
|
||||
path='/key/:npub/app/:appNpub'
|
||||
element={<AppPage />}
|
||||
/>
|
||||
<Route
|
||||
path='/key/:npub/:req_id'
|
||||
element={<ConfirmPage />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path='*' element={<Navigate to={'/welcome'} />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppRoutes
|
@@ -8,57 +8,58 @@
|
||||
// You can also remove this file if you'd prefer not to use a
|
||||
// service worker, and the Workbox build step will be skipped.
|
||||
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { StaleWhileRevalidate } from 'workbox-strategies';
|
||||
import { NoauthBackend } from './backend';
|
||||
import { clientsClaim } from 'workbox-core'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { StaleWhileRevalidate } from 'workbox-strategies'
|
||||
import { NoauthBackend } from './modules/backend'
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
declare const self: ServiceWorkerGlobalScope
|
||||
|
||||
clientsClaim();
|
||||
clientsClaim()
|
||||
|
||||
// Precache all of the assets generated by your build process.
|
||||
// Their URLs are injected into the manifest variable below.
|
||||
// This variable must be present somewhere in your service worker file,
|
||||
// even if you decide not to use precaching. See https://cra.link/PWA
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// Set up App Shell-style routing, so that all navigation requests
|
||||
// are fulfilled with your index.html shell. Learn more at
|
||||
// https://developers.google.com/web/fundamentals/architecture/app-shell
|
||||
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
|
||||
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
|
||||
registerRoute(
|
||||
// Return false to exempt requests from being fulfilled by index.html.
|
||||
({ request, url }: { request: Request; url: URL }) => {
|
||||
// If this isn't a navigation, skip.
|
||||
if (request.mode !== 'navigate') {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
// If this is a URL that starts with /_, skip.
|
||||
if (url.pathname.startsWith('/_')) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
// If this looks like a URL for a resource, because it contains
|
||||
// a file extension, skip.
|
||||
if (url.pathname.match(fileExtensionRegexp)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
// Return true to signal that we want to use the handler.
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
|
||||
);
|
||||
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'),
|
||||
)
|
||||
|
||||
// An example runtime caching route for requests that aren't handled by the
|
||||
// precache, in this case same-origin .png requests like those from in public/
|
||||
registerRoute(
|
||||
// Add in any other file extensions or routing criteria as needed.
|
||||
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
|
||||
({ url }) =>
|
||||
url.origin === self.location.origin && url.pathname.endsWith('.png'),
|
||||
// Customize this strategy as needed, e.g., by changing to CacheFirst.
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'images',
|
||||
@@ -67,24 +68,23 @@ registerRoute(
|
||||
// least-recently used images are removed.
|
||||
new ExpirationPlugin({ maxEntries: 50 }),
|
||||
],
|
||||
})
|
||||
);
|
||||
}),
|
||||
)
|
||||
|
||||
// This allows the web app to trigger skipWaiting via
|
||||
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
self.skipWaiting()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Any other custom service worker logic can go here.
|
||||
|
||||
async function start() {
|
||||
console.log("worker starting")
|
||||
console.log('worker starting')
|
||||
const backend = new NoauthBackend(self)
|
||||
await backend.start()
|
||||
}
|
||||
|
||||
start()
|
||||
|
||||
|
17
src/shared/Button/Button.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { styled, Button as MuiButton, ButtonProps } from '@mui/material'
|
||||
|
||||
const BUTTON_VARIANTS = {
|
||||
PRIMARY: 'primary',
|
||||
SECONDARY: 'secondary',
|
||||
TERTIARY: 'tertiary',
|
||||
}
|
||||
|
||||
export const Button = () => {
|
||||
return <StyledButton>Button</StyledButton>
|
||||
}
|
||||
|
||||
const StyledButton = styled((props: ButtonProps) => <MuiButton {...props} />)(
|
||||
() => {
|
||||
return {}
|
||||
},
|
||||
)
|
68
src/shared/Input/Input.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { FC } from 'react'
|
||||
import {
|
||||
Box,
|
||||
BoxProps,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
InputBase,
|
||||
InputBaseProps,
|
||||
styled,
|
||||
} from '@mui/material'
|
||||
|
||||
type InputProps = InputBaseProps & {
|
||||
helperText?: string
|
||||
containerProps?: BoxProps
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Input: FC<InputProps> = ({
|
||||
helperText,
|
||||
containerProps,
|
||||
label,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<StyledInputContainer {...containerProps}>
|
||||
{label ? (
|
||||
<FormLabel className='label' htmlFor={props.id}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
) : null}
|
||||
<InputBase className='input' {...props} />
|
||||
{helperText ? (
|
||||
<FormHelperText className='helper_text'>
|
||||
{helperText}
|
||||
</FormHelperText>
|
||||
) : null}
|
||||
</StyledInputContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledInputContainer = styled((props: BoxProps) => <Box {...props} />)(
|
||||
({ theme }) => {
|
||||
const isDark = theme.palette.mode === 'dark'
|
||||
return {
|
||||
width: '100%',
|
||||
'& > .input': {
|
||||
background: isDark ? '#000' : '#000',
|
||||
color: theme.palette.common.white,
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '1rem',
|
||||
border: '0.3px solid #FFFFFF54',
|
||||
fontSize: '0.875rem',
|
||||
'& input::placeholder': {
|
||||
color: theme.palette.common.white,
|
||||
},
|
||||
},
|
||||
'& > .helper_text': {
|
||||
margin: '0.5rem 1rem 0',
|
||||
},
|
||||
'& > .label': {
|
||||
margin: '0 1rem 0.5rem',
|
||||
display: 'block',
|
||||
color: theme.palette.primary.main,
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
16
src/shared/SectionTitle/SectionTitle.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Typography, TypographyProps, styled } from '@mui/material'
|
||||
import { FC, PropsWithChildren } from 'react'
|
||||
|
||||
export const SectionTitle: FC<PropsWithChildren> = ({ children }) => {
|
||||
return <StyledTypography>{children}</StyledTypography>
|
||||
}
|
||||
|
||||
const StyledTypography = styled((props: TypographyProps) => (
|
||||
<Typography {...props} variant='body1' />
|
||||
))(({ theme }) => ({
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '3px',
|
||||
display: 'block',
|
||||
marginBottom: '0.5rem',
|
||||
color: theme.palette.text.secondary,
|
||||
}))
|
@@ -1,70 +0,0 @@
|
||||
import type { UnsignedEvent } from "nostr-tools";
|
||||
import { generatePrivateKey, getPublicKey, getSignature } from "nostr-tools";
|
||||
|
||||
import type { NostrEvent } from '@nostr-dev-kit/ndk' // "./ndk-dist";
|
||||
import { NDKUser } from '@nostr-dev-kit/ndk' // "./ndk-dist";
|
||||
import type { NDKSigner } from '@nostr-dev-kit/ndk' // "./ndk-dist";
|
||||
import { Nip04 } from "./nip04";
|
||||
//import { decrypt, encrypt } from "./ende";
|
||||
|
||||
export class PrivateKeySigner implements NDKSigner {
|
||||
private _user: NDKUser | undefined;
|
||||
privateKey?: string;
|
||||
private nip04: Nip04
|
||||
|
||||
public constructor(privateKey?: string) {
|
||||
if (privateKey) {
|
||||
this.privateKey = privateKey;
|
||||
this._user = new NDKUser({
|
||||
hexpubkey: getPublicKey(this.privateKey),
|
||||
});
|
||||
}
|
||||
this.nip04 = new Nip04()
|
||||
}
|
||||
|
||||
public static generate() {
|
||||
const privateKey = generatePrivateKey();
|
||||
return new PrivateKeySigner(privateKey);
|
||||
}
|
||||
|
||||
public async blockUntilReady(): Promise<NDKUser> {
|
||||
if (!this._user) {
|
||||
throw new Error("NDKUser not initialized");
|
||||
}
|
||||
return this._user;
|
||||
}
|
||||
|
||||
public async user(): Promise<NDKUser> {
|
||||
await this.blockUntilReady();
|
||||
return this._user as NDKUser;
|
||||
}
|
||||
|
||||
public async sign(event: NostrEvent): Promise<string> {
|
||||
if (!this.privateKey) {
|
||||
throw Error("Attempted to sign without a private key");
|
||||
}
|
||||
|
||||
return getSignature(event as UnsignedEvent, this.privateKey);
|
||||
}
|
||||
|
||||
public async encrypt(recipient: NDKUser, value: string): Promise<string> {
|
||||
if (!this.privateKey) {
|
||||
throw Error("Attempted to encrypt without a private key");
|
||||
}
|
||||
|
||||
const recipientHexPubKey = recipient.hexpubkey;
|
||||
return await this.nip04.encrypt(this.privateKey, recipientHexPubKey, value);
|
||||
// return await encrypt(recipientHexPubKey, value, this.privateKey);
|
||||
}
|
||||
|
||||
public async decrypt(sender: NDKUser, value: string): Promise<string> {
|
||||
if (!this.privateKey) {
|
||||
throw Error("Attempted to decrypt without a private key");
|
||||
}
|
||||
|
||||
const senderHexPubKey = sender.hexpubkey;
|
||||
// console.log("nip04_decrypt", value)
|
||||
return await this.nip04.decrypt(this.privateKey, senderHexPubKey, value);
|
||||
// return await decrypt(this.privateKey, senderHexPubKey, value) as string;
|
||||
}
|
||||
}
|
5
src/store/hooks/redux.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
|
||||
import { AppDispatch, RootState } from '..'
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>()
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
52
src/store/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit'
|
||||
import { contentSlice } from './reducers/content.slice'
|
||||
import { uiSlice } from './reducers/ui.slice'
|
||||
|
||||
import {
|
||||
persistStore,
|
||||
persistReducer,
|
||||
FLUSH,
|
||||
REGISTER,
|
||||
REHYDRATE,
|
||||
PAUSE,
|
||||
PERSIST,
|
||||
PURGE,
|
||||
} from 'redux-persist'
|
||||
import storage from 'redux-persist/lib/storage' // defaults to localStorage for web
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage,
|
||||
whiteList: [uiSlice.name],
|
||||
}
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
[contentSlice.name]: contentSlice.reducer,
|
||||
[uiSlice.name]: uiSlice.reducer,
|
||||
})
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, rootReducer)
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: [
|
||||
FLUSH,
|
||||
REHYDRATE,
|
||||
PAUSE,
|
||||
PERSIST,
|
||||
PURGE,
|
||||
REGISTER,
|
||||
],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export const persistor = persistStore(store)
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
export const selectKeys = (state: RootState) => state.content.keys
|
37
src/store/reducers/content.slice.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import { DbApp, DbKey, DbPerm, DbPending } from '../../modules/db'
|
||||
|
||||
export interface IContentState {
|
||||
keys: DbKey[]
|
||||
apps: DbApp[]
|
||||
perms: DbPerm[]
|
||||
pending: DbPending[]
|
||||
}
|
||||
|
||||
const initialState: IContentState = {
|
||||
keys: [],
|
||||
apps: [],
|
||||
perms: [],
|
||||
pending: [],
|
||||
}
|
||||
|
||||
export const contentSlice = createSlice({
|
||||
name: 'content',
|
||||
initialState,
|
||||
reducers: {
|
||||
setKeys: (state, action) => {
|
||||
state.keys = action.payload.keys
|
||||
},
|
||||
setApps: (state, action) => {
|
||||
state.apps = action.payload.apps
|
||||
},
|
||||
setPerms: (state, action) => {
|
||||
state.perms = action.payload.perms
|
||||
},
|
||||
setPending: (state, action) => {
|
||||
state.pending = action.payload.pending
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setKeys, setApps, setPerms, setPending } = contentSlice.actions
|
23
src/store/reducers/ui.slice.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
type ThemeMode = 'light' | 'dark'
|
||||
|
||||
export interface UIState {
|
||||
themeMode: ThemeMode
|
||||
}
|
||||
|
||||
const initialState: UIState = {
|
||||
themeMode: 'light',
|
||||
}
|
||||
|
||||
export const uiSlice = createSlice({
|
||||
name: 'ui',
|
||||
initialState,
|
||||
reducers: {
|
||||
setThemeMode: (state, action) => {
|
||||
state.themeMode = action.payload.mode
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setThemeMode } = uiSlice.actions
|
70
src/swic.ts
@@ -1,70 +0,0 @@
|
||||
// service-worker client interface
|
||||
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
|
||||
|
||||
let swr: ServiceWorkerRegistration | null = null
|
||||
const reqs = new Map<number,{ ok: (r: any) => void, rej: (r: any) => void }>()
|
||||
let nextReqId = 1
|
||||
let onRender: (() => void) | null = null
|
||||
|
||||
export async function swicRegister() {
|
||||
serviceWorkerRegistration.register({
|
||||
onSuccess(registration) {
|
||||
console.log("sw registered")
|
||||
swr = registration
|
||||
},
|
||||
onError(e) {
|
||||
console.log(`error ${e}`)
|
||||
}
|
||||
});
|
||||
|
||||
navigator.serviceWorker.ready.then(r => swr = r)
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
onMessage((event as MessageEvent).data)
|
||||
})
|
||||
}
|
||||
|
||||
function onMessage(data: any) {
|
||||
const { id, result, error } = data
|
||||
console.log("SW message", id, result, error)
|
||||
|
||||
if (!id) {
|
||||
if (onRender) onRender()
|
||||
return
|
||||
}
|
||||
|
||||
const r = reqs.get(id)
|
||||
if (!r) {
|
||||
console.log("Unexpected message from service worker", data)
|
||||
return
|
||||
}
|
||||
|
||||
reqs.delete(id)
|
||||
if (error) r.rej(error)
|
||||
else r.ok(result)
|
||||
}
|
||||
|
||||
export async function swicCall(method: string, ...args: any[]) {
|
||||
const id = nextReqId
|
||||
nextReqId++
|
||||
|
||||
return new Promise((ok, rej) => {
|
||||
if (!swr || !swr.active) {
|
||||
rej(new Error("No active service worker"))
|
||||
return
|
||||
}
|
||||
|
||||
reqs.set(id, { ok, rej })
|
||||
const msg = {
|
||||
id,
|
||||
method,
|
||||
args: [...args],
|
||||
}
|
||||
//console.log("sending to SW", msg)
|
||||
swr.active.postMessage(msg)
|
||||
})
|
||||
}
|
||||
|
||||
export function swicOnRender(cb: () => void) {
|
||||
onRender = cb
|
||||
}
|
6
src/types/augmented-event.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
export interface AugmentedEvent extends Event {
|
||||
order: number
|
||||
identifier: string
|
||||
}
|
12
src/utils/helpers.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export async function log(s: string) {
|
||||
const log = document.getElementById('log')
|
||||
if (log) log.innerHTML = s
|
||||
}
|
||||
|
||||
export async function call(cb: () => any) {
|
||||
try {
|
||||
return await cb()
|
||||
} catch (e) {
|
||||
log(`Error: ${e}`)
|
||||
}
|
||||
}
|
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
@@ -18,9 +14,11 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|