Merge pull request #3 from ursuscamp/options_ui

Options UI
This commit is contained in:
Ryan Breen
2023-01-28 22:57:36 -05:00
committed by GitHub
20 changed files with 1551 additions and 349 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules/
*.build.js
*.build.js
Shared (Extension)/Resources/options.build.css

View File

@@ -71,6 +71,18 @@
941B04342978CDF900CA291E /* Icon-16.png in Resources */ = {isa = PBXBuildFile; fileRef = 941B042F2978CDF900CA291E /* Icon-16.png */; };
941B04352978CDF900CA291E /* Icon-64.png in Resources */ = {isa = PBXBuildFile; fileRef = 941B04302978CDF900CA291E /* Icon-64.png */; };
941B04362978CDF900CA291E /* Icon-64.png in Resources */ = {isa = PBXBuildFile; fileRef = 941B04302978CDF900CA291E /* Icon-64.png */; };
948C69D9297F887600FB3574 /* options.html in Resources */ = {isa = PBXBuildFile; fileRef = 948C69D8297F887600FB3574 /* options.html */; };
948C69DA297F887600FB3574 /* options.html in Resources */ = {isa = PBXBuildFile; fileRef = 948C69D8297F887600FB3574 /* options.html */; };
948C69DD297F88A200FB3574 /* options.css in Resources */ = {isa = PBXBuildFile; fileRef = 948C69DB297F88A200FB3574 /* options.css */; };
948C69DE297F88A200FB3574 /* options.css in Resources */ = {isa = PBXBuildFile; fileRef = 948C69DB297F88A200FB3574 /* options.css */; };
948C69DF297F88A200FB3574 /* options.js in Resources */ = {isa = PBXBuildFile; fileRef = 948C69DC297F88A200FB3574 /* options.js */; };
948C69E0297F88A200FB3574 /* options.js in Resources */ = {isa = PBXBuildFile; fileRef = 948C69DC297F88A200FB3574 /* options.js */; };
948C69E2297F891F00FB3574 /* options.build.js in Resources */ = {isa = PBXBuildFile; fileRef = 948C69E1297F891F00FB3574 /* options.build.js */; };
948C69E3297F891F00FB3574 /* options.build.js in Resources */ = {isa = PBXBuildFile; fileRef = 948C69E1297F891F00FB3574 /* options.build.js */; };
948C69E5297F8BA600FB3574 /* options.build.css in Resources */ = {isa = PBXBuildFile; fileRef = 948C69E4297F8BA600FB3574 /* options.build.css */; };
948C69E6297F8BA600FB3574 /* options.build.css in Resources */ = {isa = PBXBuildFile; fileRef = 948C69E4297F8BA600FB3574 /* options.build.css */; };
948C69E82982DFE900FB3574 /* background.html in Resources */ = {isa = PBXBuildFile; fileRef = 948C69E72982DFE900FB3574 /* background.html */; };
948C69E92982DFE900FB3574 /* background.html in Resources */ = {isa = PBXBuildFile; fileRef = 948C69E72982DFE900FB3574 /* background.html */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -159,6 +171,12 @@
941B042E2978CDF900CA291E /* Icon-32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Icon-32.png"; sourceTree = "<group>"; };
941B042F2978CDF900CA291E /* Icon-16.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Icon-16.png"; sourceTree = "<group>"; };
941B04302978CDF900CA291E /* Icon-64.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Icon-64.png"; sourceTree = "<group>"; };
948C69D8297F887600FB3574 /* options.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = options.html; sourceTree = "<group>"; };
948C69DB297F88A200FB3574 /* options.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = options.css; sourceTree = "<group>"; };
948C69DC297F88A200FB3574 /* options.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = options.js; sourceTree = "<group>"; };
948C69E1297F891F00FB3574 /* options.build.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = options.build.js; sourceTree = "<group>"; };
948C69E4297F8BA600FB3574 /* options.build.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = options.build.css; sourceTree = "<group>"; };
948C69E72982DFE900FB3574 /* background.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = background.html; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -240,6 +258,11 @@
941B03A2296FA90400CA291E /* Resources */ = {
isa = PBXGroup;
children = (
948C69E4297F8BA600FB3574 /* options.build.css */,
948C69E1297F891F00FB3574 /* options.build.js */,
948C69DB297F88A200FB3574 /* options.css */,
948C69DC297F88A200FB3574 /* options.js */,
948C69D8297F887600FB3574 /* options.html */,
941B04162971138F00CA291E /* content.build.js */,
941B04152971138F00CA291E /* nostr.build.js */,
941B04172971138F00CA291E /* popup.build.js */,
@@ -253,6 +276,7 @@
941B03A8296FA90400CA291E /* popup.html */,
941B03A9296FA90400CA291E /* popup.css */,
941B03AA296FA90400CA291E /* popup.js */,
948C69E72982DFE900FB3574 /* background.html */,
);
path = Resources;
sourceTree = "<group>";
@@ -475,7 +499,11 @@
buildActionMask = 2147483647;
files = (
941B0413297110F100CA291E /* background.build.js in Resources */,
948C69E82982DFE900FB3574 /* background.html in Resources */,
948C69DF297F88A200FB3574 /* options.js in Resources */,
948C69DD297F88A200FB3574 /* options.css in Resources */,
941B03F2296FA90400CA291E /* background.js in Resources */,
948C69E2297F891F00FB3574 /* options.build.js in Resources */,
941B03F8296FA90400CA291E /* popup.css in Resources */,
941B04292977A28700CA291E /* Icon-128.png in Resources */,
941B04182971138F00CA291E /* nostr.build.js in Resources */,
@@ -489,6 +517,8 @@
941B041C2971139000CA291E /* popup.build.js in Resources */,
941B03EC296FA90400CA291E /* _locales in Resources */,
941B04222977A25700CA291E /* Icon-512.png in Resources */,
948C69E5297F8BA600FB3574 /* options.build.css in Resources */,
948C69D9297F887600FB3574 /* options.html in Resources */,
941B03F4296FA90400CA291E /* content.js in Resources */,
941B04262977A25700CA291E /* Icon-1024.png in Resources */,
941B04352978CDF900CA291E /* Icon-64.png in Resources */,
@@ -503,7 +533,11 @@
buildActionMask = 2147483647;
files = (
941B0414297110F100CA291E /* background.build.js in Resources */,
948C69E92982DFE900FB3574 /* background.html in Resources */,
948C69E0297F88A200FB3574 /* options.js in Resources */,
948C69DE297F88A200FB3574 /* options.css in Resources */,
941B03F3296FA90400CA291E /* background.js in Resources */,
948C69E3297F891F00FB3574 /* options.build.js in Resources */,
941B03F9296FA90400CA291E /* popup.css in Resources */,
941B042A2977A28700CA291E /* Icon-128.png in Resources */,
941B04192971138F00CA291E /* nostr.build.js in Resources */,
@@ -517,6 +551,8 @@
941B041D2971139000CA291E /* popup.build.js in Resources */,
941B03ED296FA90400CA291E /* _locales in Resources */,
941B04232977A25700CA291E /* Icon-512.png in Resources */,
948C69E6297F8BA600FB3574 /* options.build.css in Resources */,
948C69DA297F887600FB3574 /* options.html in Resources */,
941B03F5296FA90400CA291E /* content.js in Resources */,
941B04272977A25700CA291E /* Icon-1024.png in Resources */,
941B04362978CDF900CA291E /* Icon-64.png in Resources */,
@@ -805,7 +841,7 @@
INFOPLIST_KEY_UIMainStoryboardFile = Main;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -847,7 +883,7 @@
INFOPLIST_KEY_UIMainStoryboardFile = Main;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@@ -29,7 +29,8 @@ This extension does not collect any user data, or transmit any data over a netwo
2. Open project folder in terminal.
3. Run `npm install` to install the dependencies.
4. Run `npm run watch` to watch and build the necessary extension files.
5. After every rebuild, execute Run in XCode to deploy the latest changes to Safari.
5. Run `npm run watch-tailwind` to watch and build the pages with tailwinds CSS.
6. After every rebuild, execute Run in XCode to deploy the latest changes to Safari.
If you do not see the Nostore extension in your Safari toolbar, you need to activate unsigned extensions and Nostore:

View File

@@ -0,0 +1 @@
<script src="background.build.js"></script>

View File

@@ -6,11 +6,15 @@ import {
nip19,
} from 'nostr-tools';
import { getProfileIndex, get, getProfile } from './utils';
const storage = browser.storage.local;
const log = msg => console.log('Background: ', msg);
browser.runtime.onInstalled.addListener(async ({ reason }) => {
// I would like to be able to skip this for development purposes
let ignoreHook = (await storage.get('ignoreInstallHook')).ignoreInstallHook;
let ignoreHook = (await storage.get({ ignoreInstallHook: false }))
.ignoreInstallHook;
if (ignoreHook === true) {
return;
}
@@ -23,56 +27,36 @@ browser.runtime.onInstalled.addListener(async ({ reason }) => {
browser.runtime.onMessage.addListener(
async (message, _sender, sendResponse) => {
console.log(message);
log(message);
switch (message.kind) {
case 'init':
await initialize();
// General
case 'log':
console.log(
message.payload.module ? `${module}: ` : '',
message.payload.msg
);
break;
case 'setProfileIndex':
await setProfileIndex(message.payload);
case 'generatePrivateKey':
sendResponse(generatePrivateKey());
break;
case 'getProfileIndex':
let profileIndex = await getProfileIndex();
sendResponse(profileIndex);
case 'savePrivateKey':
await savePrivateKey(message.payload);
break;
case 'getNsecKey':
let nsecKey = await getNsecKey();
sendResponse(nsecKey);
case 'getNpub':
let npub = await getNpub(message.payload);
sendResponse(npub);
break;
case 'getNpubKey':
let npubKey = await getNpubKey();
sendResponse(npubKey);
case 'getNsec':
let nsec = await getNsec(message.payload);
sendResponse(nsec);
break;
case 'getPubKey':
let pubKey = await getPubKey();
sendResponse(pubKey);
break;
case 'getHosts':
let hosts = await getHosts();
sendResponse(hosts);
break;
case 'getName':
let name = await getName();
sendResponse(name);
break;
case 'getProfileNames':
let profileNames = await getProfileNames();
sendResponse(profileNames);
break;
case 'newProfile':
let newIndex = await newProfile();
sendResponse(newIndex);
break;
case 'saveProfile':
await saveProfile(message.payload);
break;
case 'clearData':
await browser.storage.local.clear();
break;
case 'deleteProfile':
await deleteProfile();
break;
// window.nostr
case 'signEvent':
let event = await signEvent_(message.payload);
sendResponse(event);
@@ -86,38 +70,38 @@ browser.runtime.onMessage.addListener(
sendResponse(plainText);
break;
case 'getRelays':
sendResponse({});
let relays = await getRelays();
sendResponse(relays);
break;
default:
break;
}
return false;
}
);
async function get(item) {
return (await storage.get(item))[item];
}
async function getOrSetDefault(key, def) {
let val = (await storage.get(key))[key];
if (val == null || val == undefined) {
await storage.set({ [key]: def });
return def;
// Options
async function savePrivateKey([index, privKey]) {
if (privKey.startsWith('nsec')) {
privKey = nip19.decode(privKey).data;
}
return val;
let profiles = await get('profiles');
profiles[index].privKey = privKey;
await storage.set({ profiles });
}
async function initialize() {
await getOrSetDefault('profileIndex', 0);
await getOrSetDefault('profiles', [
{ name: 'Default', privKey: generatePrivateKey(), hosts: [] },
]);
async function getNsec(index) {
let profile = await getProfile(index);
let nsec = nip19.nsecEncode(profile.privKey);
return nsec;
}
async function getNsecKey() {
let profile = await currentProfile();
return profile.nsecKey;
async function getNpub(index) {
let profile = await getProfile(index);
let pubKey = getPublicKey(profile.privKey);
let npub = nip19.npubEncode(pubKey);
return npub;
}
async function getPrivKey() {
@@ -125,81 +109,18 @@ async function getPrivKey() {
return profile.privKey;
}
async function getNpubKey() {
let pubKey = await getPubKey();
console.log('pubKey: ', pubKey);
let npubKey = nip19.npubEncode(pubKey);
console.log('npub key: ', npubKey);
return npubKey;
}
async function getPubKey() {
let privKey = await getPrivKey();
let pubKey = getPublicKey(privKey);
return pubKey;
}
async function getHosts() {
let profile = await currentProfile();
return profile.hosts;
}
async function getName() {
let profile = await currentProfile();
return profile.name;
}
async function getProfileNames() {
let profiles = await get('profiles');
return profiles.map(p => p.name);
}
async function setProfileIndex(profileIndex) {
await storage.set({ profileIndex });
}
async function getProfileIndex() {
return await get('profileIndex');
}
async function currentProfile() {
let index = await get('profileIndex');
let index = await getProfileIndex();
let profiles = await get('profiles');
let currentProfile = profiles[index];
currentProfile.nsecKey = nip19.nsecEncode(currentProfile.privKey);
return profiles[index];
}
async function newProfile() {
let profiles = await get('profiles');
const newProfile = {
name: 'New Profile',
privKey: generatePrivateKey(),
hosts: [],
};
profiles.push(newProfile);
await storage.set({ profiles });
return profiles.length - 1;
}
async function saveProfile(profile) {
if (profile.privKey.startsWith('nsec')) {
profile.privKey = nip19.decode(profile.privKey).data;
}
let index = await getProfileIndex();
let profiles = await get('profiles');
profiles[index] = profile;
await storage.set({ profiles });
}
async function deleteProfile() {
let index = await getProfileIndex();
let profiles = await get('profiles');
profiles.splice(index, 1);
let profileIndex = Math.max(index - 1, 0);
await storage.set({ profiles, profileIndex });
}
async function signEvent_(event) {
event = { ...event };
let privKey = await getPrivKey();
@@ -216,3 +137,15 @@ async function nip04Decrypt({ pubKey, cipherText }) {
let privKey = await getPrivKey();
return nip04.decrypt(privKey, pubKey, cipherText);
}
async function getRelays() {
let profile = await currentProfile();
let relays = profile.relays;
let relayObj = {};
// The getRelays call expects this to be returned as an object, not array
relays.forEach(relay => {
let { url, read, write } = relay;
relayObj[url] = { read, write };
});
return relayObj;
}

View File

@@ -1,11 +1,9 @@
{
"manifest_version": 3,
"default_locale": "en",
"name": "__MSG_extension_name__",
"description": "__MSG_extension_description__",
"version": "1.0",
"icons": {
"48": "images/icon-48.png",
"96": "images/icon-96.png",
@@ -13,17 +11,19 @@
"256": "images/icon-256.png",
"512": "images/icon-512.png"
},
"background": {
"service_worker": "background.build.js",
"type": "module"
"page": "background.html"
},
"content_scripts": [{
"js": [ "content.build.js" ],
"matches": [ "<all_urls>" ]
}],
"content_scripts": [
{
"js": [
"content.build.js"
],
"matches": [
"<all_urls>"
]
}
],
"action": {
"default_popup": "popup.html",
"default_icon": {
@@ -35,17 +35,32 @@
"72": "images/toolbar-72.png"
}
},
"permissions": [ "storage" ],
"web_accessible_resources": [
{
"resources": ["nostr.build.js", "popup.build.js"],
"matches": ["<all_urls>"]
}
"options_ui": {
"page": "options.html"
},
"permissions": [
"storage"
],
"web_accessible_resources": [
{
"resources": [
"nostr.build.js",
"popup.build.js",
"options.build.js",
"options.build.css",
"options.html"
],
"matches": [
"<all_urls>"
]
}
],
"content_security_policy": {
"extension_pages": "script-src 'self' 'unsafe-eval'"
},
"browser_specific_settings": {
"safari": {
"strict_min_version": "15.4"
}
}
}

View File

@@ -23,7 +23,6 @@ window.nostr = {
let reqId = Math.random().toString();
return new Promise((resolve, _reject) => {
this.requests[reqId] = resolve;
console.log(`Event ${reqId}: ${kind}, payload: `, payload);
window.postMessage({ kind, reqId, payload }, '*');
});
},
@@ -57,7 +56,6 @@ window.addEventListener('message', message => {
if (!validEvents.includes(kind)) return;
console.log(`Event ${reqId}: Received payload:`, payload);
window.nostr.requests[reqId](payload);
delete window.nostr.requests[reqId];
});

View File

@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.button {
/* Colors */
@apply bg-fuchsia-900 hover:bg-fuchsia-800 active:bg-fuchsia-700 text-fuchsia-200 disabled:bg-gray-200 disabled:text-black;
/* Sizing and padding */
@apply rounded-lg p-1 md:p-1.5 md:w-24 min-w-fit text-center;
}
.input {
/* Colors */
@apply bg-fuchsia-200 text-fuchsia-800 disabled:bg-gray-200 disabled:text-black focus:border-fuchsia-800;
/* Sizing and padding */
@apply rounded-lg p-1 lg:p-1.5 w-full md:w-64;
}
.checkbox {
/* Colors */
@apply text-fuchsia-800 bg-fuchsia-200 rounded-full accent-fuchsia-200;
/* Sizing and padding */
@apply w-4 h-4 lg:w-5 lg:h-5;
}
.section {
@apply border-2 border-fuchsia-700 rounded-lg p-1 md:p-5 mt-6 shadow-md;
}
.section-header {
@apply text-2xl lg:text-5xl font-bold;
}
}

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="options.build.css">
<script defer src="options.build.js"></script>
</head>
<body x-data="options" class="text-fuchsia-900 p-3.5 lg:p-32">
<h1 class="text-3xl lg:text-6xl font-bold md:text-center">Settings</h1>
<!-- PROFILES -->
<div class="mt-6">
<label for="profiles">Profile</label>
<br>
<select class="input" x-model.number="profileIndex" id="profiles">
<template x-for="(name, index) in profileNames" :key="index">
<option x-text="name" :value="index"></option>
</template>
</select>
<div class="block md:inline p-3 pl-0 md:p-0">
<button class="button" @click="await newProfile()">New</button>
<button class="button" @click="confirmDelete = true" x-show="!confirmDelete">Delete</button>
<button class="button" @click="deleteProfile" x-show="confirmDelete">Confirm Delete</button>
</div>
</div>
<!-- KEYS -->
<div class="section">
<h2 class="section-header">Keys</h2>
<p class="text-sm italic">Provide your <code class="not-italic">nsec</code> or legacy (hexadecimal) private keys.
</p>
<form @submit.prevent="saveProfile">
<div class="mt-3">
<label for="profile-name">Profile Name</label>
<br>
<input x-model="profileName" type="text" class="input" autocapitalize="off" autocomplete="off" spellcheck="off">
</div>
<div class="mt-3">
<label for="priv-key">Private Key</label>
<br>
<input x-model="privKey" type="text" class="input" autocapitalize="off" autocomplete="off" spellcheck="off">
</div>
<div class="mt-3">
<label for="pub-key">Public Key</label>
<br>
<input x-model="pubKey" type="text" class="input" disabled>
</div>
<div class="mt-3">
<button class="button" :disabled="!needsSave" @click="saveProfile">Save</button>
</div>
</form>
</div>
<!-- RELAYS -->
<div class="section">
<h2 class="section-header">Relays</h2>
<p class="text-sm italic">Add relay suggestions for clients.</p>
<template x-if="hasRelays">
<table class="mt-3 text-xs md:text-base table-auto md:table-fixed">
<thead class="font-bold text-lg">
<td class="p-2 text-center">URL</td>
<td class="p-2 text-center">Read</td>
<td class="p-2 text-center">Write</td>
<td class="p-2 text-center">Actions</td>
</thead>
<template x-for="(relay, index) in relays" :key="index">
<tr>
<td class="p-2 w-1/3" x-text="relay.url"></td>
<td class="p-2 text-center">
<input class="checkbox" type="checkbox" x-model="relay.read" @change="await saveRelays()">
</td>
<td class="p-2 text-center">
<input class="checkbox" type="checkbox" x-model="relay.write" @change="await saveRelays()">
</td>
<td class="p-2 text-center">
<button class="button" @click="await deleteRelay(index)">Delete</button>
</td>
</tr>
</template>
</table>
</template>
<template x-if="!hasRelays">
<div class="mt-3">
There are no relays assigned to this profile.
</div>
</template>
<div class="mt-3" x-show="hasRecommendedRelays">
<select x-model="recommendedRelay" class="input">
<option value="" disabled selected>Recommended Relays</option>
<template x-for="relay in recommendedRelays">
<option :value="relay" x-text="relay"></option>
</template>
</select>
</div>
<input class="mt-3 input" x-model="newRelay" type="text" @keyup.enter="await addRelay()" placeholder="wss://..."
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="off">
<div class="block md:inline p-3 pl-0 md:p-0">
<button class="button" @click="await addRelay()">Add</button>
</div>
<div class="text-red-500 font-bold" x-show="urlError.length > 0" x-text="urlError"></div>
</div>
<div class="mt-6">
<button class="button" @click="window.close()">Close</button>
<button class="button" @click="confirmClear = true" x-show="!confirmClear">Clear Data</button>
<button class="button" @click="clearData" x-show="confirmClear">Confirm Clear</button>
</div>
</body>
</html>

View File

@@ -0,0 +1,196 @@
import Alpine from 'alpinejs';
import {
clearData,
deleteProfile,
getProfileIndex,
getProfileNames,
getRelays,
initialize,
newProfile,
savePrivateKey,
saveProfileName,
saveRelays,
RECOMMENDED_RELAYS,
} from './utils';
const log = console.log;
Alpine.data('options', () => ({
profileNames: ['Poop'],
profileIndex: 0,
profileName: '',
pristineProfileName: '',
privKey: '',
pristinePrivKey: '',
pubKey: '',
relays: [],
newRelay: '',
urlError: '',
recommendedRelay: '',
confirmDelete: false,
confirmClear: false,
async init(watch = true) {
log('Initialize backend.');
await initialize();
if (watch) {
this.$watch('profileIndex', async () => {
await this.refreshProfile();
});
this.$watch('recommendedRelay', async () => {
if (this.recommendedRelay.length == 0) return;
await this.addRelay(this.recommendedRelay);
this.recommendedRelay = '';
});
}
// We need to refresh the names BEFORE setting the profile index, or it won't work
// on init to set the correct profile.
await this.getProfileNames();
await this.getProfileIndex();
await this.refreshProfile();
},
async refreshProfile() {
await this.getProfileNames();
await this.getProfileName();
await this.getNsec();
await this.getNpub();
await this.getRelays();
this.confirmClear = false;
this.confirmDelete = false;
},
// Profile functions
async getProfileNames() {
this.profileNames = await getProfileNames();
},
async getProfileName() {
let names = await getProfileNames();
let name = names[this.profileIndex];
this.profileName = name;
this.pristineProfileName = name;
},
async getProfileIndex() {
this.profileIndex = await getProfileIndex();
},
async newProfile() {
let newIndex = await newProfile();
await this.getProfileNames();
this.profileIndex = newIndex;
},
async deleteProfile() {
await deleteProfile(this.profileIndex);
await this.init(false);
},
// Key functions
async saveProfile() {
if (!this.needsSave) return;
await savePrivateKey(this.profileIndex, this.privKey);
await saveProfileName(this.profileIndex, this.profileName);
await this.getProfileNames();
await this.refreshProfile();
},
async getNpub() {
this.pubKey = await browser.runtime.sendMessage({
kind: 'getNpub',
payload: this.profileIndex,
});
},
async getNsec() {
this.privKey = await browser.runtime.sendMessage({
kind: 'getNsec',
payload: this.profileIndex,
});
this.pristinePrivKey = this.privKey;
},
// Relay functions
async getRelays() {
this.relays = await getRelays(this.profileIndex);
},
async saveRelays() {
await saveRelays(this.profileIndex, this.relays);
await this.getRelays();
},
async addRelay(relayToAdd = null) {
let newRelay = relayToAdd || this.newRelay;
try {
let url = new URL(newRelay);
if (url.protocol !== 'wss:') {
this.setUrlError('Must be a websocket url');
return;
}
let urls = this.relays.map(v => v.url);
if (urls.includes(url.href)) {
this.setUrlError('URL already exists');
return;
}
this.relays.push({ url: url.href, read: true, write: true });
await this.saveRelays();
this.newRelay = '';
} catch (error) {
this.setUrlError('Invalid websocket URL');
}
},
async deleteRelay(index) {
this.relays.splice(index, 1);
await this.saveRelays();
},
setUrlError(message) {
this.urlError = message;
setTimeout(() => {
this.urlError = '';
}, 3000);
},
// General
async clearData() {
await clearData();
await this.init(false);
},
// Properties
get recommendedRelays() {
let relays = this.relays.map(r => new URL(r.url)).map(r => r.href);
return RECOMMENDED_RELAYS.filter(r => !relays.includes(r.href)).map(
r => r.href
);
},
get hasRelays() {
return this.relays.length > 0;
},
get hasRecommendedRelays() {
return this.recommendedRelays.length > 0;
},
get needsSave() {
return (
this.privKey !== this.pristinePrivKey ||
this.profileName !== this.pristineProfileName
);
},
}));
Alpine.start();

View File

@@ -5,61 +5,23 @@
body {
width: 300px;
padding: 10px;
padding: 15px;
font-family: system-ui;
}
label {
display: inline-block;
width: 200px;
}
input {
width: 100%;
}
.profile-buttons {
width: 100%;
}
#priv-key, #pub-key {
font-family: monospace;
}
.profiles {
margin-bottom: 15px;
}
.profile-name {
margin-bottom: 15px;
}
.key {
margin-bottom: 15px;
}
.buttons {
margin-bottom: 15px;
}
td:first-child {
width: 50px;
}
td:nth-child(2) {
width: 100px;
}
tr {
margin-bottom: 10px;
.relay {
margin-top: 10px;
font-size: 80%;
color: darkred;
}
.help {
margin-top: 15px;
margin-top: 10px;
}
.disclaimer {
margin-top: 10px;
font-size: 50%;
color: green;
}

View File

@@ -17,50 +17,20 @@
<option x-text="prof" :value="index"></option>
</template>
</select>
<button @click="newProfile">New</button>
<button @click="confirmDelete = true" x-show="!confirmDelete"
:disabled="profileNames.length <= 1">Delete</button>
<button @click="await deleteProfile()" x-show="confirmDelete">Confirm Delete</button>
</div>
</div>
<div class="profile-name">
<label for="profile-name">Profile Name</label>
<input type="text" id="profile-name" x-model="name">
</div>
<div class="key">
<label for="priv-key">Private Key</label>
<input id="priv-key" x-model="privKey" :type="visibleKey ? 'text' : 'password'">
</div>
<div class="buttons">
<button @click="visibleKey = !visibleKey" x-text="visibleKey ? 'Hide' : 'Show'"></button>
<button @click="await saveProfile()" :disabled="!needsSaving">Save</button>
<button @click="confirmClear = true" x-show="!confirmClear">Clear Data</button>
<button @click="await clearData()" x-show="confirmClear">Confirm Clear</button>
</div>
<div x-show="hasValidPubKey">
<label for="pub-key">Pub Key:</label>
<input type="text" id="pub-key" x-model="pubKey" disabled>
</div>
<div class="allowed-sites" x-show="hosts.length > 0">
<h3>Allowed Sites</h3>
<table>
<template x-for="(host, index) in hosts" :key="host.host">
<tr>
<td class="allowed" x-text="host.allowed ? 'Yes' : 'No'"></td>
<td x-text="host.host"></td>
<td><button @click="deleteSite(index)">Delete</button></td>
</tr>
</template>
</table>
<div class="relay" x-show="relayCount < 1">
<span>
You do not have any relays setup for this profile. Would you like to add some recommended
relays now?
</span>
<br>
<button @click="await addRelays()">Add Relays</button>
</div>
<div class="help">
<button @click='window.open("https://ursus.camp/nostore", "_blank")'>Get Help</button>
<button @click="await openOptions()">Settings</button>
</div>
<div class="disclaimer">

View File

@@ -1,131 +1,76 @@
import {
bglog,
getProfileNames,
setProfileIndex,
getProfileIndex,
getRelays,
RECOMMENDED_RELAYS,
saveRelays,
} from './utils';
import Alpine from 'alpinejs';
window.Alpine = Alpine;
const log = console.log;
Alpine.data('popup', () => ({
privKey: '',
pubKey: '',
pristinePrivKey: '',
hosts: [],
name: '',
pristineName: '',
profileNames: ['Default'],
profileIndex: 0,
visibleKey: false,
confirmClear: false,
confirmDelete: false,
relayCount: 0,
async init() {
console.log('Initializing backend.');
log('Initializing backend.');
await browser.runtime.sendMessage({ kind: 'init' });
this.$watch('profileIndex', async () => {
await this.loadNames();
await this.setProfileIndex();
await this.refreshProfile();
this.confirmClear = false;
this.confirmDelete = false;
await this.countRelays();
});
// Even though getProfileIndex will immediately trigger a profile refresh, we still
// Even though loadProfileIndex will immediately trigger a profile refresh, we still
// need to do an initial profile refresh first. This will pull the latest data from
// the background scripts. Specifically, this pulls the list of profile names,
// otherwise it generates a rendering error where it may not show the correct selected
// profile when first loading the popup.
await this.refreshProfile();
await this.getProfileIndex();
},
async refreshProfile() {
await this.getNsecKey();
await this.getNpubKey();
await this.getHosts();
await this.getName();
await this.getProfileNames();
await this.loadNames();
await this.loadProfileIndex();
await this.countRelays();
},
async setProfileIndex() {
// Becauset the popup state resets every time it open, we use null as a guard. That way
// whenever the user opens the popup, it doesn't automatically reset the current profile
if (this.profileIndex !== null) {
await browser.runtime.sendMessage({
kind: 'setProfileIndex',
payload: this.profileIndex,
});
await setProfileIndex(this.profileIndex);
}
},
async getNsecKey() {
this.privKey = await browser.runtime.sendMessage({
kind: 'getNsecKey',
});
this.pristinePrivKey = this.privKey;
async loadNames() {
this.profileNames = await getProfileNames();
},
async getNpubKey() {
this.pubKey = await browser.runtime.sendMessage({ kind: 'getNpubKey' });
async loadProfileIndex() {
this.profileIndex = await getProfileIndex();
},
async getHosts() {
this.hosts = await browser.runtime.sendMessage({ kind: 'getHosts' });
async openOptions() {
await browser.runtime.openOptionsPage();
window.close();
},
async getProfileNames() {
this.profileNames = await browser.runtime.sendMessage({
kind: 'getProfileNames',
});
async countRelays() {
let relays = await getRelays(this.profileIndex);
this.relayCount = relays.length;
},
async getName() {
this.name = await browser.runtime.sendMessage({ kind: 'getName' });
this.pristineName = this.name;
},
async getProfileIndex() {
this.profileIndex = await browser.runtime.sendMessage({
kind: 'getProfileIndex',
});
},
async newProfile() {
let newIndex = await browser.runtime.sendMessage({
kind: 'newProfile',
});
await this.refreshProfile();
this.profileIndex = newIndex;
},
async saveProfile() {
let { name, privKey, hosts } = this;
let profile = { name, privKey, hosts };
await browser.runtime.sendMessage({
kind: 'saveProfile',
payload: profile,
});
await this.refreshProfile();
},
async clearData() {
await browser.runtime.sendMessage({ kind: 'clearData' });
await this.init(); // Re-initialize after clearing
this.confirmClear = false;
},
async deleteProfile() {
await browser.runtime.sendMessage({ kind: 'deleteProfile' });
await this.init();
this.confirmDelete = false;
},
// Properties
get hasValidPubKey() {
return typeof this.pubKey === 'string' && this.pubKey.length > 0;
},
get needsSaving() {
return (
this.privKey !== this.pristinePrivKey ||
this.name !== this.pristineName
);
async addRelays() {
let relays = RECOMMENDED_RELAYS.map(r => ({
url: r.href,
read: true,
write: true,
}));
await saveRelays(this.profileIndex, relays);
await this.countRelays();
},
}));

View File

@@ -0,0 +1,129 @@
const storage = browser.storage.local;
export const RECOMMENDED_RELAYS = [
new URL('wss://relay.damus.io'),
new URL('wss://eden.nostr.land'),
new URL('wss://nostr-relay.derekross.me'),
new URL('wss://relay.snort.social'),
];
export async function initialize() {
await getOrSetDefault('profileIndex', 0);
await getOrSetDefault('profiles', [await generateProfile()]);
await getOrSetDefault('version', 0);
}
export async function bglog(msg, module = null) {
await browser.runtime.sendMessage({
kind: 'log',
payload: { msg, module },
});
}
export async function getProfiles() {
let profiles = await storage.get({ profiles: [] });
return profiles.profiles;
}
export async function getProfile(index) {
let profiles = await getProfiles();
return profiles[index];
}
export async function getProfileNames() {
let profiles = await getProfiles();
return profiles.map(p => p.name);
}
export async function getProfileIndex() {
const index = await storage.get({ profileIndex: 0 });
return index.profileIndex;
}
export async function setProfileIndex(profileIndex) {
await storage.set({ profileIndex });
}
export async function deleteProfile(index) {
let profiles = await getProfiles();
let profileIndex = await getProfileIndex();
profiles.splice(index, 1);
if (profiles.length == 0) {
await clearData(); // If we have deleted all of the profiles, let's just start fresh with all new data
await initialize();
} else {
// If the index deleted was the active profile, change the active profile to the next one
let newIndex =
profileIndex === index ? Math.max(index - 1, 0) : this.profileIndex;
await storage.set({ profiles, profileIndex: newIndex });
}
}
export async function clearData() {
let ignoreInstallHook = await storage.get({ ignoreInstallHook: false });
await storage.clear();
await storage.set(ignoreInstallHook);
}
async function generatePrivateKey() {
return await browser.runtime.sendMessage({ kind: 'generatePrivateKey' });
}
export async function generateProfile(name = 'Default') {
return {
name,
privKey: await generatePrivateKey(),
hosts: [],
relays: [],
};
}
async function getOrSetDefault(key, def) {
let val = (await storage.get(key))[key];
if (val == null || val == undefined) {
await storage.set({ [key]: def });
return def;
}
return val;
}
export async function saveProfileName(index, profileName) {
let profiles = await getProfiles();
profiles[index].name = profileName;
await storage.set({ profiles });
}
export async function savePrivateKey(index, privateKey) {
await browser.runtime.sendMessage({
kind: 'savePrivateKey',
payload: [index, privateKey],
});
}
export async function newProfile() {
let profiles = await getProfiles();
const newProfile = await generateProfile('New Profile');
profiles.push(newProfile);
await storage.set({ profiles });
return profiles.length - 1;
}
export async function getRelays(profileIndex) {
let profile = await getProfile(profileIndex);
return profile.relays || [];
}
export async function saveRelays(profileIndex, relays) {
// Having an Alpine proxy object as a sub-object does not serialize correctly in storage,
// so we are pre-serializing here before assigning it to the profile, so the proxy
// obj doesn't bug out.
let fixedRelays = JSON.parse(JSON.stringify(relays));
let profiles = await getProfiles();
let profile = profiles[profileIndex];
profile.relays = fixedRelays;
await storage.set({ profiles });
}
export async function get(item) {
return (await storage.get(item))[item];
}

View File

@@ -1,21 +1,28 @@
#!/usr/bin/env node
let watch = process.argv[2] === 'watch' ? {
onRebuild(error, result) {
if (error) console.error('watch rebuild failed: ', error)
else console.log('watch rebuild succeeded: ', result)
}
} : false;
let watch =
process.argv[2] === 'watch'
? {
onRebuild(error, result) {
if (error) console.error('watch rebuild failed: ', error);
else console.log('watch rebuild succeeded: ', result);
},
}
: false;
require('esbuild').build({
entryPoints: {
'background.build': './Shared (Extension)/Resources/background.js',
'content.build': './Shared (Extension)/Resources/content.js',
'nostr.build': './Shared (Extension)/Resources/nostr.js',
'popup.build': './Shared (Extension)/Resources/popup.js',
},
outdir: './Shared (Extension)/Resources',
sourcemap: 'inline',
bundle: true,
watch
}).catch(() => process.exit(1))
require('esbuild')
.build({
entryPoints: {
'background.build': './Shared (Extension)/Resources/background.js',
'content.build': './Shared (Extension)/Resources/content.js',
'nostr.build': './Shared (Extension)/Resources/nostr.js',
'popup.build': './Shared (Extension)/Resources/popup.js',
'options.build': './Shared (Extension)/Resources/options.js',
},
outdir: './Shared (Extension)/Resources',
sourcemap: 'inline',
bundle: true,
// minify: true,
watch,
})
.catch(() => process.exit(1));

View File

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

839
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,16 +11,19 @@
"scripts": {
"build": "./build.js",
"watch": "./build.js watch",
"watch-tailwind": "tailwindcss -i './Shared (Extension)/Resources/options.css' -o './Shared (Extension)/Resources/options.build.css' --watch",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"alpinejs": "^3.10.5",
"esbuild": "^0.16.17",
"nostr-tools": "^1.1.1"
},
"devDependencies": {
"prettier": "^2.8.3"
"esbuild": "^0.16.17",
"@tailwindcss/forms": "^0.5.3",
"prettier": "^2.8.3",
"tailwindcss": "^3.2.4"
}
}
}

8
tailwind.config.js Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./Shared (Extension)/**/*.{html,js}'],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/forms')],
};