aboutsummaryrefslogtreecommitdiff
path: root/extension/src
diff options
context:
space:
mode:
Diffstat (limited to 'extension/src')
-rw-r--r--extension/src/assets/tailwind.scss354
-rw-r--r--extension/src/bg-api/bg-api.ts145
-rw-r--r--extension/src/bg-api/content-script.ts48
-rw-r--r--extension/src/bg-api/options.ts80
-rw-r--r--extension/src/bg-api/popup.ts66
-rw-r--r--extension/src/bg-api/types.ts50
-rw-r--r--extension/src/entries/background/auth-api.ts135
-rw-r--r--extension/src/entries/background/history.ts82
-rw-r--r--extension/src/entries/background/identity-api.ts61
-rw-r--r--extension/src/entries/background/main.ts105
-rw-r--r--extension/src/entries/background/nostr-api.ts124
-rw-r--r--extension/src/entries/background/permissions.ts80
-rw-r--r--extension/src/entries/background/script.js16
-rw-r--r--extension/src/entries/background/server-api/endpoints.ts72
-rw-r--r--extension/src/entries/background/server-api/index.ts80
-rw-r--r--extension/src/entries/background/serviceWorker.ts16
-rw-r--r--extension/src/entries/background/settings.ts125
-rw-r--r--extension/src/entries/background/types.ts76
-rw-r--r--extension/src/entries/contentScript/nostr-shim.js87
-rw-r--r--extension/src/entries/contentScript/primary/App.vue17
-rw-r--r--extension/src/entries/contentScript/primary/components/PromptPopup.vue148
-rw-r--r--extension/src/entries/contentScript/primary/main.js41
-rw-r--r--extension/src/entries/contentScript/primary/style.scss15
-rw-r--r--extension/src/entries/contentScript/renderContent.js48
-rw-r--r--extension/src/entries/nostr-provider.js92
-rw-r--r--extension/src/entries/options/App.vue199
-rw-r--r--extension/src/entries/options/components/Identities.vue188
-rw-r--r--extension/src/entries/options/components/Privacy.vue9
-rw-r--r--extension/src/entries/options/components/SiteSettings.vue235
-rw-r--r--extension/src/entries/options/index.html21
-rw-r--r--extension/src/entries/options/main.js33
-rw-r--r--extension/src/entries/popup/App.vue22
-rw-r--r--extension/src/entries/popup/Components/IdentitySelection.vue46
-rw-r--r--extension/src/entries/popup/Components/Login.vue40
-rw-r--r--extension/src/entries/popup/Components/PageContent.vue104
-rw-r--r--extension/src/entries/popup/index.html11
-rw-r--r--extension/src/entries/popup/main.js32
-rw-r--r--extension/src/manifest.js113
38 files changed, 3216 insertions, 0 deletions
diff --git a/extension/src/assets/tailwind.scss b/extension/src/assets/tailwind.scss
new file mode 100644
index 0000000..7d1b1f6
--- /dev/null
+++ b/extension/src/assets/tailwind.scss
@@ -0,0 +1,354 @@
+@tailwind base;
+
+@tailwind components;
+
+@tailwind utilities;
+
+#injected-root {
+
+ /* HEADINGS */
+ h1, h2, h3, h4, h5, h6{
+ @apply font-medium leading-tight mt-0 mb-2;
+ }
+ h1{
+ @apply sm:text-5xl text-4xl;
+ }
+ h2{
+ @apply sm:text-4xl text-3xl;
+ }
+ h3{
+ @apply sm:text-3xl text-2xl;
+ }
+ h4{
+ @apply sm:text-2xl text-xl;
+ }
+ h5{
+ @apply sm:text-xl text-lg;
+ }
+ h6{
+ @apply sm:text-base text-sm;
+ }
+
+
+ input.primary,
+ select.primary,
+ textarea.primary {
+ @apply border-2 rounded-md p-2 py-1.5 border-gray-200;
+ @apply dark:bg-dark-800 dark:border-dark-400 dark:text-white;
+
+ &:focus,
+ &::after,
+ &:active{
+ @apply outline-none border-primary-500 dark:border-primary-600;
+ }
+
+ &.error,
+ &.error:focus,
+ &.error::after,
+ &.error:active{
+ @apply outline-none border-red-500 dark:border-red-400;
+ }
+ }
+
+ /* CHECKBOXES */
+
+ label.checkbox{
+ @apply flex items-center cursor-pointer;
+
+ input[type="checkbox"] {
+ @apply ease-in-out duration-100 w-5 h-5;
+ @apply border-2 rounded-sm border-gray-300 dark:border-dark-500;
+
+ &:checked {
+ @apply text-primary-500 dark:text-primary-600 border-primary-500 dark:border-primary-600;
+ }
+ }
+
+ &.primary {
+ input[type="checkbox"]{
+ @apply appearance-none;
+ @apply hover:border-primary-500 dark:hover:border-primary-600;
+
+ &:checked {
+ @apply bg-primary-500 dark:bg-primary-600 border-primary-500 dark:border-primary-600;
+ }
+
+ & + span.check{
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
+ @apply bg-white dark:bg-dark-500;
+ }
+ }
+
+ span.check{
+ margin: 0px 0px 1px 4px;
+ @apply absolute h-3 w-3;
+ }
+ }
+
+ }
+
+ /*Select */
+
+ select.primary.options{
+ @apply text-current;
+ }
+
+
+ /*Validation inputs*/
+
+ input.primary.dirty.data-valid,
+ select.primary.dirty.data-valid
+ {
+ @apply border-primary-500 dark:border-primary-600;
+ }
+ input.primary.dirty.data-invalid,
+ select.primary.dirty.data-invalid
+ {
+ @apply border-red-600 dark:border-red-500;
+ }
+
+
+ input,
+ input:hover,
+ input:focus,
+ input:active,
+ input::after {
+ @apply duration-100 ease-in-out outline-none;
+ }
+
+ .default-page-template {
+ min-height: 400px;
+ @apply container w-full max-w-4xl px-4 pt-3 mx-auto sm:pt-6 md:px-0;
+ }
+
+ #header-mobile-nav a:hover,
+ #header-desktop-nav a:hover,
+ #header-mobile-nav .router-link-active,
+ #header-desktop-nav .router-link-active,
+ footer .footer-content .router-link-active {
+ @apply text-primary-500 dark:text-primary-600;
+ }
+
+ #header-mobile-nav a {
+ @apply text-xl;
+ }
+
+ a.link {
+ @apply duration-150 ease-in-out;
+ @apply text-purple-500 hover:text-purple-600;
+ }
+
+ .modal-entry {
+ background: #00000077;
+ @apply fixed z-50 flex w-full;
+
+ .modal-content-container {
+ @apply w-full max-w-md p-5 m-auto rounded-md shadow-2xl md:mt-44 mt-28;
+ @apply bg-white border border-transparent dark:bg-dark-600 dark:border-primary-500 dark:text-white;
+
+ .modal-title {
+ @apply text-xl font-bold;
+ }
+
+ .modal-description {
+ @apply text-sm;
+ }
+ }
+
+ .modal-button-container {
+ @apply flex flex-row justify-end pt-3 gap-3;
+ }
+
+ .input-container {
+ @apply pt-5;
+
+ input {
+ @apply w-full;
+ }
+ }
+ }
+
+ .btn {
+ @apply ease-in-out duration-100 border border-transparent px-4 py-2 text-center text-sm font-medium transition-all focus:ring-2;
+
+ @apply bg-white border-gray-300 text-gray-700 shadow-sm hover:bg-gray-100 focus:ring-gray-100;
+
+ .dark & {
+ @apply bg-transparent text-inherit border-dark-300 hover:bg-transparent hover:border-gray-400 focus:ring-gray-300;
+ }
+
+ &.b-0 {
+ @apply border-0 ring-0;
+ }
+
+ &:disabled {
+ @apply cursor-not-allowed border-gray-100 bg-gray-50 text-gray-400;
+ @apply dark:bg-transparent dark:border-dark-400 dark:text-dark-300;
+ }
+
+ &.sm {
+ @apply px-3 py-1.5 text-sm;
+ }
+
+ &.xs {
+ @apply px-2 py-1;
+ }
+
+ &.primary {
+ @apply border-primary-500 bg-primary-500 text-white hover:border-primary-600 hover:bg-primary-600 focus:ring-primary-200;
+
+ &:disabled {
+ @apply bg-primary-300 border-primary-300;
+ @apply dark:bg-transparent dark:border-primary-400;
+ }
+
+ .dark & {
+ @apply border-primary-600 bg-transparent text-primary-600 hover:border-primary-500 hover:text-primary-500 focus:ring-primary-500;
+ }
+ }
+
+ &.secondary {
+ @apply border-secondary-500 bg-secondary-500 text-white hover:border-secondary-600 hover:bg-secondary-600 focus:ring-secondary-200;
+
+ &:disabled {
+ @apply bg-secondary-300 border-secondary-300;
+ @apply dark:bg-transparent dark:border-secondary-400;
+ }
+
+ .dark & {
+ @apply border-secondary-600 bg-transparent text-secondary-600 hover:border-secondary-500 hover:text-secondary-500 focus:ring-secondary-500;
+ }
+ }
+
+ &.red {
+ @apply border-red-500 bg-red-500 text-white hover:border-red-600 hover:bg-red-600 focus:ring-red-200;
+
+ &:disabled {
+ @apply bg-red-300 border-red-300;
+ @apply dark:bg-transparent dark:border-red-400;
+ }
+
+ .dark & {
+ @apply border-red-600 bg-transparent text-red-600 hover:border-red-500 hover:text-red-500 focus:ring-red-500;
+ }
+ }
+
+ &.blue {
+ @apply border-blue-500 bg-blue-500 text-white hover:border-blue-600 hover:bg-blue-600 focus:ring-blue-200;
+
+ &:disabled {
+ @apply bg-blue-300 border-blue-300;
+ @apply dark:bg-transparent dark:border-blue-400;
+ }
+
+ .dark & {
+ @apply border-blue-600 bg-transparent text-blue-600 hover:border-blue-500 hover:text-blue-500 focus:ring-blue-500;
+ }
+ }
+
+ &.green {
+ @apply border-green-500 bg-green-500 text-white hover:border-green-600 hover:bg-green-600 focus:ring-green-200;
+
+ &:disabled {
+ @apply bg-green-300 border-green-300;
+ @apply dark:bg-transparent dark:border-green-400;
+ }
+
+ .dark & {
+ @apply border-green-600 bg-transparent text-green-600 hover:border-green-500 hover:text-green-500 focus:ring-green-500;
+ }
+ }
+
+ &.yellow {
+ @apply border-yellow-500 bg-yellow-500 text-white hover:border-yellow-600 hover:bg-yellow-600 focus:ring-yellow-200;
+
+ &:disabled {
+ @apply bg-yellow-300 border-yellow-300;
+ @apply dark:bg-transparent dark:border-yellow-400;
+ }
+
+ .dark & {
+ @apply border-yellow-400 bg-transparent text-yellow-400 hover:border-yellow-300 hover:text-yellow-300 focus:ring-yellow-300;
+ }
+ }
+
+ &.purple {
+ @apply border-purple-500 bg-purple-500 text-white hover:border-purple-600 hover:bg-purple-600 focus:ring-purple-200;
+
+ &:disabled {
+ @apply bg-purple-300 border-purple-300;
+ @apply dark:bg-transparent dark:border-purple-400;
+ }
+
+ .dark & {
+ @apply border-purple-600 bg-transparent text-purple-600 hover:border-purple-500 hover:text-purple-500 focus:ring-purple-500;
+ }
+ }
+
+ &.pink {
+ @apply border-pink-500 bg-pink-500 text-white hover:border-pink-600 hover:bg-pink-600 focus:ring-pink-200;
+
+ &:disabled {
+ @apply bg-pink-300 border-pink-300;
+ @apply dark:bg-transparent dark:border-pink-400;
+ }
+
+ .dark & {
+ @apply border-pink-600 bg-transparent text-pink-600 hover:border-pink-500 hover:text-pink-500 focus:ring-pink-500;
+ }
+ }
+
+ &.gray {
+ @apply border-gray-500 bg-gray-500 text-white hover:border-gray-600 hover:bg-gray-600 focus:ring-gray-200;
+
+ &:disabled {
+ @apply bg-gray-300 border-gray-300;
+ @apply dark:bg-transparent dark:border-gray-400;
+ }
+
+ .dark & {
+ @apply border-gray-600 bg-transparent text-gray-600 hover:border-gray-500 hover:text-gray-500 focus:ring-gray-500;
+ }
+ }
+
+ &.no-border{
+ @apply border-0 hover:border-0 focus:border-0 ring-0 focus:ring-0 bg-transparent hover:bg-transparent focus:bg-transparent shadow-none hover:shadow-none focus:shadow-none;
+ }
+ }
+
+ .button-group {
+ @apply inline-flex -space-x-0 divide-x overflow-hidden rounded-lg border border-transparent shadow-sm;
+ @apply divide-gray-300 border-gray-300 dark:divide-dark-500 dark:border-dark-500;
+
+ & .btn {
+ @apply border-0 ring-0 focus:ring-0;
+ }
+ }
+
+ .general-toast {
+ .notification-title {
+ font-size: 16px;
+ }
+
+ .notification-content {
+ font-size: 14px;
+ }
+
+ .vue-notification {
+ @apply duration-200 ease-in-out shadow-md hover:shadow-lg;
+ }
+ }
+
+ .form-toast {
+ left: calc(50% - 150px);
+ @apply pt-2 mx-auto mb-3;
+
+ .notification-title {
+ font-size: 14px;
+ }
+
+ .notification-content {
+ font-size: 12px;
+ }
+ }
+} \ No newline at end of file
diff --git a/extension/src/bg-api/bg-api.ts b/extension/src/bg-api/bg-api.ts
new file mode 100644
index 0000000..86e6b68
--- /dev/null
+++ b/extension/src/bg-api/bg-api.ts
@@ -0,0 +1,145 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import { assign, isEqual, orderBy, transform } from 'lodash'
+import { apiCall, debugLog } from "@vnuge/vnlib.browser"
+import { reactive, toRefs } from "vue"
+import { useWindowFocus } from '@vueuse/core'
+import { NostrPubKey } from '../entries/background/types'
+import { ClientStatus, NostrIdentiy, SendMessageHandler, UseStatusResult, PluginConfig } from './types'
+
+
+//Hold local status
+const status = reactive<ClientStatus>({
+ loggedIn: false,
+ userName: '',
+ selectedKey: undefined,
+ darkMode: true
+})
+
+const focused = useWindowFocus()
+
+const updateStatusAsync = async (sendMessage: SendMessageHandler) => {
+
+ //Get the status from the background script
+ const result = await sendMessage<ClientStatus>('getStatus', {}, 'background')
+
+ const ls = status as any;
+ const res = result as any;
+
+ //Check if the status has changed
+ for (const key in result) {
+ if (!isEqual(ls[key], res)){
+ //Update the status and break
+ assign(status, result)
+ break;
+ }
+ }
+
+ //Get the selected publicKey
+ const selected = await sendMessage<NostrPubKey>('getPublicKey', {}, 'background')
+
+ if(!isEqual(status.selectedKey, selected)){
+ debugLog('Selected key changed')
+ assign(status, { selectedKey: selected })
+ }
+}
+
+/**
+ * Keeps a reactive status object that up to date with the background script
+ * @returns {Readonly<Ref<{}>>}
+ */
+export const useStatus = (sendMessage: SendMessageHandler, bypassFocus : boolean): UseStatusResult => {
+ //Configure timer get status from the background, only when the window is focused
+ setInterval(() => (bypassFocus || focused.value) ? updateStatusAsync(sendMessage) : null, 200);
+
+ //return a refs object
+ return {
+ toRefs: () => toRefs(status),
+ update: () => updateStatusAsync(sendMessage)
+ }
+}
+
+export const useManagment = (sendMessage: SendMessageHandler) =>{
+
+
+ const getProfile = async () => {
+ //Send the login request to the background script
+ return await apiCall(async () => await sendMessage('getProfile', {}, 'background'))
+ }
+
+ const getAllKeys = async (): Promise<NostrPubKey[]> => {
+ //Send the login request to the background script
+ const keys = (await apiCall(async () => await sendMessage('getAllKeys', {}, 'background')) ?? []) as NostrPubKey[]
+
+ const formattedKeys = transform(keys, (result, key) => {
+ result.push({
+ ...key,
+ Created: new Date(key.Created).toLocaleString(),
+ LastModified: new Date(key.LastModified).toLocaleString()
+ })
+ }, [] as NostrPubKey[])
+
+ return orderBy(formattedKeys, 'Created', 'desc')
+ }
+
+ const selectKey = async (key: NostrPubKey) => {
+ await apiCall(async () => {
+ //Send the login request to the background script
+ await sendMessage('selectKey', { ...key }, 'background')
+ })
+ //Update the status after the key is selected
+ updateStatusAsync(sendMessage)
+ }
+
+ const createIdentity = async (identity: NostrIdentiy) => {
+ await apiCall(async ({toaster}) => {
+ //Send the login request to the background script
+ await sendMessage('createIdentity', { ...identity }, 'background')
+ toaster.form.success({
+ title: 'Success',
+ text: 'Identity created successfully'
+ })
+ })
+ }
+
+ const updateIdentity = async (identity: NostrIdentiy) => {
+ await apiCall(async ({toaster}) => {
+ //Send the login request to the background script
+ await sendMessage('updateIdentity', { ...identity }, 'background')
+ toaster.form.success({
+ title: 'Success',
+ text: 'Identity updated successfully'
+ })
+ })
+ }
+
+ const getSiteConfig = async (): Promise<PluginConfig | undefined> => {
+ return await apiCall(async () => {
+ //Send the login request to the background script
+ return await sendMessage<PluginConfig>('getSiteConfig', {}, 'background')
+ })
+ }
+
+ return {
+ getProfile,
+ getAllKeys,
+ selectKey,
+ createIdentity,
+ updateIdentity,
+ getSiteConfig
+ }
+} \ No newline at end of file
diff --git a/extension/src/bg-api/content-script.ts b/extension/src/bg-api/content-script.ts
new file mode 100644
index 0000000..7b64e81
--- /dev/null
+++ b/extension/src/bg-api/content-script.ts
@@ -0,0 +1,48 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import { apiCall } from "@vnuge/vnlib.browser";
+import { useManagment as _mgmt, useStatus as _sts } from "./bg-api"
+import { sendMessage } from "webext-bridge/content-script"
+
+export const useStatus = (() => {
+ const status = _sts(sendMessage, false);
+
+ return () => {
+ const refs = status.toRefs();
+ //run status when called and dont await
+ status.update();
+ return refs;
+ }
+})()
+
+export const useManagment = (() => {
+ const mgmt = _mgmt(sendMessage);
+
+ const isEnabledSite = async () => {
+ await apiCall(async ({ toaster }) => {
+
+ //Send the login request to the background script
+ const data = await sendMessage('isSiteEnabled', { }, 'background')
+ })
+ }
+
+ return () => {
+ return {
+ ...mgmt,
+ }
+ }
+})()
diff --git a/extension/src/bg-api/options.ts b/extension/src/bg-api/options.ts
new file mode 100644
index 0000000..4313f35
--- /dev/null
+++ b/extension/src/bg-api/options.ts
@@ -0,0 +1,80 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import { apiCall } from "@vnuge/vnlib.browser";
+import { useManagment as _mgmt, useStatus as _sts } from "./bg-api"
+import { sendMessage } from "webext-bridge/options"
+import { truncate } from "lodash";
+import { NostrIdentiy, PluginConfig } from "./types";
+
+enum HistoryType {
+ get = 'get',
+ clear = 'clear',
+ remove = 'remove',
+ push = 'push'
+}
+
+interface HistoryMessage{
+ readonly action: string,
+ readonly event?: any
+}
+
+export const useManagment = (() => {
+ const mgmt = _mgmt(sendMessage);
+
+ const saveSiteConfig = async (config: PluginConfig) => {
+ await apiCall(async ({ toaster }) => {
+ //Send the login request to the background script
+ await sendMessage('setSiteConfig', { ...config }, 'background')
+
+ toaster.form.info({
+ title: 'Saved',
+ text: 'Site config saved'
+ })
+ })
+ }
+
+ const deleteIdentity = async (key: NostrIdentiy) => {
+ await apiCall(async ({ toaster }) => {
+ //Delete the desired key async, if it fails it will throw
+ await sendMessage('deleteKey', { ...key }, 'background')
+
+ toaster.form.success({
+ title: 'Success',
+ text: `Successfully delete key ${truncate(key.Id, { length: 7 })}`
+ })
+ })
+ }
+
+ return () => {
+ return {
+ ...mgmt,
+ saveSiteConfig,
+ deleteIdentity
+ }
+ }
+})()
+
+export const useStatus = (() => {
+ //Bypass the window focus check for the options page
+ const status = _sts(sendMessage, true);
+ return () => {
+ const refs = status.toRefs();
+ //run status when called and dont await
+ status.update();
+ return refs;
+ }
+})()
diff --git a/extension/src/bg-api/popup.ts b/extension/src/bg-api/popup.ts
new file mode 100644
index 0000000..f81bcfb
--- /dev/null
+++ b/extension/src/bg-api/popup.ts
@@ -0,0 +1,66 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { useManagment as _mgmt, useStatus as _sts } from "./bg-api"
+import { sendMessage } from "webext-bridge/popup"
+import { apiCall, debugLog } from "@vnuge/vnlib.browser"
+
+export const useManagment = (() =>{
+ const mgmt = _mgmt(sendMessage);
+
+ const login = async (token: string) => {
+ await apiCall(async ({ toaster }) => {
+
+ //Send the login request to the background script
+ await sendMessage('login', { token }, 'background')
+
+ toaster.form.success({
+ title: 'Success',
+ text: 'Logged in successfully'
+ })
+ })
+ }
+
+ const logout = async () => {
+ await apiCall(async ({ toaster }) => {
+ //Send the login request to the background script
+ await sendMessage('logout', {}, 'background')
+
+ toaster.form.success({
+ title: 'Success',
+ text: 'Successfully logged out'
+ })
+ })
+ }
+
+ return () => {
+ return {
+ ...mgmt,
+ login,
+ logout
+ }
+ }
+})()
+
+export const useStatus = (() =>{
+ const status = _sts(sendMessage, false);
+
+ return () => {
+ const refs = status.toRefs();
+ //run status when called and dont await
+ status.update();
+ return refs
+ }
+})() \ No newline at end of file
diff --git a/extension/src/bg-api/types.ts b/extension/src/bg-api/types.ts
new file mode 100644
index 0000000..6fc2f84
--- /dev/null
+++ b/extension/src/bg-api/types.ts
@@ -0,0 +1,50 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import { ToRefs } from 'vue';
+import { NostrPubKey } from '../entries/background/types'
+import { JsonObject } from "type-fest";
+
+export interface ClientStatus extends JsonObject {
+ readonly loggedIn: boolean;
+ readonly userName: string;
+ readonly selectedKey?: NostrPubKey;
+ readonly darkMode: boolean;
+}
+
+export interface NostrIdentiy extends NostrPubKey {
+ readonly UserName: string;
+ readonly ExistingKey: string;
+}
+
+export interface SendMessageHandler {
+ <T extends JsonObject>(action: string, data: any, context: string): Promise<T>
+}
+
+export interface UseStatusResult {
+ toRefs: () => ToRefs<ClientStatus>,
+ update: () => Promise<void>
+}
+
+export interface PluginConfig extends JsonObject {
+ readonly apiUrl: string;
+ readonly accountBasePath: string;
+ readonly nostrEndpoint: string;
+ readonly heartbeat: boolean;
+ readonly maxHistory: number;
+ readonly darkMode: boolean;
+ readonly autoInject: boolean;
+} \ No newline at end of file
diff --git a/extension/src/entries/background/auth-api.ts b/extension/src/entries/background/auth-api.ts
new file mode 100644
index 0000000..ed18b5b
--- /dev/null
+++ b/extension/src/entries/background/auth-api.ts
@@ -0,0 +1,135 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { debugLog, useAxios, usePkiAuth, useSession, useSessionUtils, useUser } from "@vnuge/vnlib.browser";
+import { AxiosInstance } from "axios";
+import { runtime } from "webextension-polyfill";
+import { BridgeMessage } from "webext-bridge";
+import { useSettings } from "./settings";
+import { JsonObject } from "type-fest";
+import { ClientStatus, LoginMessage } from "./types";
+
+interface ApiHandle {
+ axios: AxiosInstance
+}
+
+export interface ProectedHandler<T extends JsonObject> {
+ (message: BridgeMessage<T>): Promise<any>
+}
+
+export const useAuthApi = (() => {
+
+ const { loggedIn } = useSession();
+ const { clearLoginState } = useSessionUtils();
+ const { logout, getProfile, heartbeat, userName } = useUser();
+ const { currentConfig } = useSettings();
+
+ const apiCall = async <T>(asyncFunc: (h: ApiHandle) => Promise<T>): Promise<T> => {
+ try {
+ //Get configured axios instance from vnlib
+ const axios = useAxios(null);
+
+ //Exec the async function
+ return await asyncFunc({ axios })
+ } catch (errMsg) {
+ debugLog(errMsg)
+ // See if the error has an axios response
+ throw { ...errMsg };
+ }
+ }
+
+ const protect = <T extends JsonObject>(cbHandler: ProectedHandler<T>) =>{
+ return (message: BridgeMessage<T>) : Promise<any> => {
+ if (message.sender.context === 'options' || message.sender.context === 'popup') {
+ return cbHandler(message)
+ }
+ throw new Error('Unauthorized')
+ }
+ }
+
+ const onLogin = protect(async ({data} : BridgeMessage<LoginMessage>): Promise<any> => {
+
+ //Perform login
+ return await apiCall(async ({ axios }) => {
+ const { login } = usePkiAuth(`${currentConfig.value.accountBasePath}/pki`);
+ await login(data.token)
+ return true;
+ })
+ })
+
+ const onLogout = protect(async () : Promise<void> => {
+ return await apiCall(async () => {
+ await logout()
+ //Cleanup after logout
+ clearLoginState()
+ })
+ })
+
+ const onGetProfile = protect(async () : Promise<any> => {
+ return await apiCall(async () => await getProfile())
+ })
+
+ const onGetStatus = async (): Promise<ClientStatus> => {
+ return {
+ //Logged in if the cookie is set and the api flag is set
+ loggedIn: loggedIn.value,
+ //username
+ userName: userName.value,
+ //dark mode flag
+ darkMode: currentConfig.value.darkMode
+ }
+ }
+
+ //We can send post messages to the server heartbeat endpoint to get status
+ const runHeartbeat = async () => {
+ //Only run if the api thinks its logged in, and config is enabled
+ if (!loggedIn.value || currentConfig.value.heartbeat !== true) {
+ return
+ }
+
+ try {
+ //Post against the heartbeat endpoint
+ await heartbeat()
+ }
+ catch (error) {
+ if (error.response?.status === 401 || error.response?.status === 403) {
+ //If we get a 401, the user is no longer logged in
+ clearLoginState()
+ }
+ }
+ }
+
+ //Setup autoheartbeat
+ runtime.onInstalled.addListener(async () => {
+ //Configure interval to run every 5 minutes to update the status
+ setInterval(runHeartbeat, 60 * 1000);
+
+ //Run immediately
+ runHeartbeat();
+ });
+
+ return () => {
+ return{
+ loggedIn,
+ apiCall,
+ protect,
+ userName,
+ onLogin,
+ onLogout,
+ onGetProfile,
+ onGetStatus
+ }
+ }
+})() \ No newline at end of file
diff --git a/extension/src/entries/background/history.ts b/extension/src/entries/background/history.ts
new file mode 100644
index 0000000..b3f3733
--- /dev/null
+++ b/extension/src/entries/background/history.ts
@@ -0,0 +1,82 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { runtime, storage } from "webextension-polyfill";
+import { useSettings } from "./settings";
+import { isEqual, remove } from "lodash";
+import { ref } from "vue";
+
+const evHistory = ref([]);
+
+export interface HistoryEvent extends Object{
+
+}
+
+export const useHistory = (() => {
+ const { currentConfig } = useSettings();
+
+ const pushEvent = (event: HistoryEvent) => {
+
+ //Limit the history to 50 events
+ if (evHistory.value.length > currentConfig.value.maxHistory) {
+ evHistory.value.shift();
+ }
+
+ evHistory.value.push(event);
+
+ //Save the history but dont wait for it
+ storage.local.set({ eventHistory: evHistory });
+ }
+
+ const getHistory = (): HistoryEvent[] => {
+ return [...evHistory.value];
+ }
+
+ const clearHistory = () => {
+ evHistory.value.length = 0;
+ storage.local.set({ eventHistory: evHistory });
+ }
+
+ const removeItem = (event: HistoryEvent) => {
+ //Remove the event from the history
+ remove(evHistory.value, (ev) => isEqual(ev, event));
+ //Save the history but dont wait for it
+ storage.local.set({ eventHistory: evHistory });
+ }
+
+ const onStartup = async () => {
+ //Recover the history array
+ const { eventHistory } = await storage.local.get('eventHistory');
+
+ //Push the history into the array
+ evHistory.value.push(...eventHistory);
+ }
+
+ //Reload the history on startup
+ runtime.onStartup.addListener(onStartup);
+ runtime.onInstalled.addListener(onStartup);
+
+ return () =>{
+ return {
+ pushEvent,
+ getHistory,
+ clearHistory,
+ removeItem
+ }
+ }
+})()
+
+
+//Listen for messages \ No newline at end of file
diff --git a/extension/src/entries/background/identity-api.ts b/extension/src/entries/background/identity-api.ts
new file mode 100644
index 0000000..612f36e
--- /dev/null
+++ b/extension/src/entries/background/identity-api.ts
@@ -0,0 +1,61 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { useAuthApi } from "./auth-api";
+import { useSettings } from "./settings";
+
+export const useIdentityApi = (() => {
+
+ const { apiCall, protect } = useAuthApi();
+ const { currentConfig } = useSettings();
+
+ const onCreateIdentity = protect(async ({data}) => {
+ //Create a new identity
+ return await apiCall(async ({ axios }) => {
+ const response = await axios.put(`${currentConfig.value.nostrEndpoint}?type=identity`, data)
+
+ if (response.data.success) {
+ return response.data.result;
+ }
+ //If we get here, the login failed
+ throw { response }
+ })
+ })
+
+ const onUpdateIdentity = protect(async ({data}) => {
+ return await apiCall(async ({ axios }) => {
+
+ delete data.Created;
+ delete data.LastModified;
+
+ //Create a new identity
+ const response = await axios.patch(`${currentConfig.value.nostrEndpoint}?type=identity`, data)
+
+ if (response.data.success) {
+ return response.data.result;
+ }
+ //If we get here, the login failed
+ throw { response }
+ })
+ })
+
+ return () =>{
+ return{
+ onCreateIdentity,
+ onUpdateIdentity
+ }
+ }
+
+})() \ No newline at end of file
diff --git a/extension/src/entries/background/main.ts b/extension/src/entries/background/main.ts
new file mode 100644
index 0000000..b4080d6
--- /dev/null
+++ b/extension/src/entries/background/main.ts
@@ -0,0 +1,105 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { runtime } from "webextension-polyfill";
+import { HistoryEvent, useHistory } from "./history";
+import { useNostrApi } from "./nostr-api";
+import { useIdentityApi } from "./identity-api";
+import { useSettings } from "./settings";
+import { onMessage } from "webext-bridge/background";
+import { useAuthApi } from "./auth-api";
+import { JsonObject } from "type-fest";
+
+//Init the history api
+useHistory();
+
+runtime.onInstalled.addListener(() => {
+ console.info("Extension installed successfully");
+});
+
+
+//Register settings handlers
+const { onGetSiteConfig, onSetSitConfig } = useSettings();
+
+onMessage('getSiteConfig', onGetSiteConfig);
+onMessage('setSiteConfig', onSetSitConfig);
+
+//Register the api handlers
+const { onGetProfile, onGetStatus, onLogin, onLogout, protect } = useAuthApi();
+
+onMessage('getProfile', onGetProfile);
+onMessage('getStatus', onGetStatus);
+onMessage('login', onLogin);
+onMessage('logout', onLogout);
+
+//Register the identity handlers
+const { onCreateIdentity, onUpdateIdentity } = useIdentityApi();
+
+onMessage('createIdentity', onCreateIdentity);
+onMessage('updateIdentity', onUpdateIdentity);
+
+//Register the nostr handlers
+const {
+ onGetPubKey,
+ onSelectKey,
+ onSignEvent,
+ onGetAllKeys,
+ onGetRelays,
+ onNip04Decrypt,
+ onNip04Encrypt,
+ onDeleteKey,
+ onSetRelay
+} = useNostrApi();
+
+onMessage('getPublicKey', onGetPubKey);
+onMessage('selectKey', onSelectKey);
+onMessage('signEvent', onSignEvent);
+onMessage('getAllKeys', onGetAllKeys);
+onMessage('getRelays', onGetRelays);
+onMessage('setRelay', onSetRelay);
+onMessage('deleteKey', onDeleteKey);
+onMessage('nip04.decrypt', onNip04Decrypt);
+onMessage('nip04.encrypt', onNip04Encrypt);
+
+//Use history api
+const { getHistory, clearHistory, removeItem, pushEvent } = useHistory();
+
+enum HistoryType {
+ get = 'get',
+ clear = 'clear',
+ remove = 'remove',
+ push = 'push'
+}
+
+interface HistoryMessage extends JsonObject {
+ action: HistoryType,
+ event: string
+}
+
+onMessage <HistoryMessage>('history', protect(async ({data}) =>{
+ switch(data.action){
+ case HistoryType.get:
+ return getHistory();
+ case HistoryType.clear:
+ clearHistory();
+ break;
+ case HistoryType.remove:
+ removeItem(data.event);
+ break;
+ case HistoryType.push:
+ pushEvent(data.event);
+ break;
+ }
+})) \ No newline at end of file
diff --git a/extension/src/entries/background/nostr-api.ts b/extension/src/entries/background/nostr-api.ts
new file mode 100644
index 0000000..fb9130b
--- /dev/null
+++ b/extension/src/entries/background/nostr-api.ts
@@ -0,0 +1,124 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { useSettings } from "./settings";
+import { useAuthApi } from "./auth-api";
+import { computed, ref, watch } from "vue";
+
+import { find, isArray } from "lodash";
+import { BridgeMessage } from "webext-bridge";
+import { NostrRelay, NostrPubKey, EventMessage, NostrEvent } from './types'
+import { Endpoints, initApi } from "./server-api";
+
+export const useNostrApi = (() => {
+
+ const { currentConfig } = useSettings();
+ const { apiCall, protect, loggedIn } = useAuthApi();
+
+ const nostrUrl = computed(() => currentConfig.value.nostrEndpoint || '/nostr')
+
+ //Init the api endpooints
+ const { execRequest } = initApi(nostrUrl);
+
+ //Get the current selected key
+ const selectedKey = ref<NostrPubKey | null>({} as NostrPubKey)
+
+ const onGetPubKey = () => {
+ //Selected key is allowed from content script
+ return { ...selectedKey.value }
+ }
+
+ const onDeleteKey = protect<NostrPubKey>(({ data }) => apiCall(() => execRequest<NostrPubKey>(Endpoints.DeleteKey, data)))
+
+ const onSelectKey = protect<NostrPubKey>(async ({ data }) => {
+ //Set the selected key to the value
+ selectedKey.value = data
+ })
+
+ const onGetAllKeys = protect(async () => {
+ return await apiCall(async () => {
+
+ //Get the keys from the server
+ const data = await execRequest<NostrPubKey[]>(Endpoints.GetKeys);
+
+ //Response must be an array of key objects
+ if (!isArray(data)) {
+ return [];
+ }
+
+ //Make sure the selected keyid is in the list, otherwise unselect the key
+ if (data?.length > 0) {
+ if (!find(data, k => k.Id === selectedKey.value?.Id)) {
+ selectedKey.value = null;
+ }
+ }
+
+ return [ ...data ]
+ })
+ })
+
+ //Unprotect this handler so it can be called from the content script
+ const onSignEvent = (async ({ data }: BridgeMessage<EventMessage>) => {
+ //Set the key id from our current selection
+ data.event.KeyId = selectedKey.value?.Id || ''; //Pass key selection error to server
+
+ //Sign the event
+ return await apiCall(async () => {
+ //Sign the event
+ const event = await execRequest<NostrEvent>(Endpoints.SignEvent, data.event);
+ return { event };
+ })
+ })
+
+ const onGetRelays = async () => {
+ return await apiCall(async () => {
+ //Get preferred relays for the current user
+ const data = await execRequest<NostrRelay[]>(Endpoints.GetRelays)
+ return [ ...data ]
+ })
+ }
+
+
+ const onSetRelay = protect<NostrRelay>(({ data }) => apiCall(() => execRequest<NostrRelay>(Endpoints.SetRelay, data)));
+
+ const onNip04Encrypt = protect(async ({ data }) => {
+ console.log('nip04.encrypt', data)
+ return { ciphertext: 'ciphertext' }
+ })
+
+ const onNip04Decrypt = protect(async ({ data }) => {
+ console.log('nip04.decrypt', data)
+ return { plaintext: 'plaintext' }
+ })
+
+ //Clear the selected key if the user logs out
+ watch(loggedIn, (li) => li ? null : selectedKey.value = null)
+
+ return () => {
+ return{
+ selectedKey,
+ nostrUrl,
+ onGetPubKey,
+ onSelectKey,
+ onGetAllKeys,
+ onSignEvent,
+ onGetRelays,
+ onSetRelay,
+ onNip04Encrypt,
+ onNip04Decrypt,
+ onDeleteKey
+ }
+ }
+})() \ No newline at end of file
diff --git a/extension/src/entries/background/permissions.ts b/extension/src/entries/background/permissions.ts
new file mode 100644
index 0000000..f12c84c
--- /dev/null
+++ b/extension/src/entries/background/permissions.ts
@@ -0,0 +1,80 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { useStorageAsync } from "@vueuse/core";
+import { find, isEmpty, remove } from "lodash";
+import { storage } from "webextension-polyfill";
+import { useAuthApi } from "./auth-api";
+import { useSettings } from "./settings";
+
+const permissions = useStorageAsync("permissions", [], storage.local);
+
+export const setAutoAllow = async (origin, mKind, keyId) => {
+ permissions.value.push({ origin, mKind, keyId, })
+}
+
+/**
+ * Determines if the user has previously allowed the origin to use the key to sign events
+ * of the desired kind
+ * @param {*} origin The site origin requesting the permission
+ * @param {*} mKind The kind of message being signed
+ * @param {*} keyId The keyId of the key being used to sign the message
+ */
+export const isAutoAllow = async (origin, mKind, keyId) => {
+ return find(permissions.value, p => p.origin === origin && p.mKind === mKind && p.keyId === keyId) !== undefined
+}
+
+/**
+ * Removes the auto allow permission from the list
+ * @param {*} origin The site origin requesting the permission
+ * @param {*} mKind The message kind being signed
+ * @param {*} keyId The keyId of the key being used to sign the message
+ */
+export const removeAutoAllow = async (origin, mKind, keyId) => {
+ //Remove the permission from the list
+ remove(permissions.value, p => p.origin === origin && p.mKind === mKind && p.keyId === keyId);
+}
+
+
+export const useSitePermissions = (() => {
+
+ const { apiCall, protect } = useAuthApi();
+ const { currentConfig } = useSettings();
+
+
+ const getCurrentPerms = async () => {
+ const { permissions } = await storage.local.get('permissions');
+
+ //Store a default config if none exists
+ if (isEmpty(permissions)) {
+ await storage.local.set({ siteConfig: defaultConfig });
+ }
+
+ //Merge the default config with the site config
+ return merge(defaultConfig, siteConfig)
+ }
+
+ const onIsSiteEnabled = protect(async ({ data }) => {
+
+ })
+
+ return () => {
+ return {
+ onCreateIdentity,
+ onUpdateIdentity
+ }
+ }
+
+})() \ No newline at end of file
diff --git a/extension/src/entries/background/script.js b/extension/src/entries/background/script.js
new file mode 100644
index 0000000..b0211d1
--- /dev/null
+++ b/extension/src/entries/background/script.js
@@ -0,0 +1,16 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import "./main.ts";
diff --git a/extension/src/entries/background/server-api/endpoints.ts b/extension/src/entries/background/server-api/endpoints.ts
new file mode 100644
index 0000000..a7f1488
--- /dev/null
+++ b/extension/src/entries/background/server-api/endpoints.ts
@@ -0,0 +1,72 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import { useAxios } from "@vnuge/vnlib.browser";
+import { Method } from "axios";
+
+export interface EndpointDefinition {
+ readonly method: Method
+ path(request?: any): string
+ onRequest: (request?: any) => Promise<any>
+ onResponse: (response: any, request?: any) => Promise<any>
+}
+
+export interface EndpointDefinitionReg<T extends string> extends EndpointDefinition {
+ readonly id: T
+}
+
+export const initEndponts = () => {
+
+ const endpoints = new Map<string, EndpointDefinition>();
+
+ const registerEndpoint = <T extends string>(def: EndpointDefinitionReg<T>) => {
+ //Store the handler by its id
+ endpoints.set(def.id, def);
+ return def;
+ }
+
+ const getEndpoint = <T extends string>(id: T): EndpointDefinition | undefined => {
+ return endpoints.get(id);
+ }
+
+ const execRequest = async <T>(id: string, request?: any): Promise<T> => {
+ const endpoint = getEndpoint(id);
+ if (!endpoint) {
+ throw new Error(`Endpoint ${id} not found`);
+ }
+
+ //Compute the path from the request
+ const path = endpoint.path(request);
+
+ //Execute the request handler
+ const req = await endpoint.onRequest(request);
+
+ //Get axios
+ const axios = useAxios(null);
+
+ //Exec the request
+ const { data } = await axios({ method: endpoint.method, url: path, data: req });
+
+ //exec the response handler and return its result
+ return await endpoint.onResponse(data, request);
+ }
+
+ return {
+ registerEndpoint,
+ getEndpoint,
+ execRequest
+ }
+}
diff --git a/extension/src/entries/background/server-api/index.ts b/extension/src/entries/background/server-api/index.ts
new file mode 100644
index 0000000..3e1ada0
--- /dev/null
+++ b/extension/src/entries/background/server-api/index.ts
@@ -0,0 +1,80 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import { Ref } from "vue"
+import { WebMessage } from "@vnuge/vnlib.browser"
+import { initEndponts } from "./endpoints"
+import { NostrEvent, NostrPubKey, NostrRelay } from "../types"
+
+export enum Endpoints {
+ GetKeys = 'getKeys',
+ DeleteKey = 'deleteKey',
+ SignEvent = 'signEvent',
+ GetRelays = 'getRelays',
+ SetRelay = 'setRelay',
+ Encrypt = 'encrypt',
+ Decrypt = 'decrypt',
+}
+
+export const initApi = (nostrUrl: Ref<string>) => {
+ const { registerEndpoint, execRequest } = initEndponts()
+
+ registerEndpoint({
+ id: Endpoints.GetKeys,
+ method: 'GET',
+ path: () => `${nostrUrl.value}?type=getKeys`,
+ onRequest: () => Promise.resolve(),
+ onResponse: (response) => Promise.resolve(response)
+ })
+
+ registerEndpoint({
+ id: Endpoints.DeleteKey,
+ method: 'DELETE',
+ path: (key: NostrPubKey) => `${nostrUrl.value}?type=identity&key_id=${key.Id}`,
+ onRequest: () => Promise.resolve(),
+ onResponse: (response: WebMessage) => response.getResultOrThrow()
+ })
+
+ registerEndpoint({
+ id: Endpoints.SignEvent,
+ method: 'POST',
+ path: () => `${nostrUrl.value}?type=signEvent`,
+ onRequest: (event: NostrEvent) => Promise.resolve(event),
+ onResponse: async (response: WebMessage<NostrEvent>) => {
+ return response.getResultOrThrow()
+ }
+ })
+
+ registerEndpoint({
+ id: Endpoints.GetRelays,
+ method: 'GET',
+ path: () => `${nostrUrl.value}?type=getRelays`,
+ onRequest: () => Promise.resolve(),
+ onResponse: (response) => Promise.resolve(response)
+ })
+
+ registerEndpoint({
+ id: Endpoints.SetRelay,
+ method: 'POST',
+ path: () => `${nostrUrl.value}?type=relay`,
+ onRequest: (relay: NostrRelay) => Promise.resolve(relay),
+ onResponse: (response) => Promise.resolve(response)
+ })
+
+ return {
+ execRequest
+ }
+} \ No newline at end of file
diff --git a/extension/src/entries/background/serviceWorker.ts b/extension/src/entries/background/serviceWorker.ts
new file mode 100644
index 0000000..cb6f42a
--- /dev/null
+++ b/extension/src/entries/background/serviceWorker.ts
@@ -0,0 +1,16 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import "./main";
diff --git a/extension/src/entries/background/settings.ts b/extension/src/entries/background/settings.ts
new file mode 100644
index 0000000..98d6aa6
--- /dev/null
+++ b/extension/src/entries/background/settings.ts
@@ -0,0 +1,125 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { runtime, storage } from "webextension-polyfill"
+import { isEmpty, isEqual, merge } from 'lodash'
+import { configureApi, debugLog } from '@vnuge/vnlib.browser'
+import { readonly, ref } from "vue";
+import { BridgeMessage } from "webext-bridge";
+import { JsonObject } from "type-fest";
+
+export interface PluginConfig extends JsonObject {
+ readonly apiUrl: string;
+ readonly accountBasePath: string;
+ readonly nostrEndpoint: string;
+ readonly heartbeat: boolean;
+ readonly maxHistory: number;
+ readonly darkMode: boolean;
+ readonly autoInject: boolean;
+}
+
+//Default storage config
+const defaultConfig : PluginConfig = {
+ apiUrl: import.meta.env.VITE_API_URL,
+ accountBasePath: import.meta.env.VITE_ACCOUNTS_BASE_PATH,
+ nostrEndpoint: import.meta.env.VITE_NOSTR_ENDPOINT,
+ heartbeat: import.meta.env.VITE_HEARTBEAT_ENABLED === 'true',
+ maxHistory: 50,
+ darkMode: false,
+ autoInject: true
+};
+
+export const useSettings = (() =>{
+
+ const currentConfig = ref<PluginConfig>({} as PluginConfig);
+
+ const getCurrentConfig = async () => {
+ const { siteConfig } = await storage.local.get('siteConfig');
+
+ //Store a default config if none exists
+ if(isEmpty(siteConfig)){
+ await storage.local.set({ siteConfig: defaultConfig });
+ }
+
+ //Merge the default config with the site config
+ return merge(defaultConfig, siteConfig)
+ }
+
+ const restoreApiSettings = async () => {
+ //Set the current config
+ currentConfig.value = await getCurrentConfig();;
+
+ //Configure the vnlib api
+ configureApi({
+ session: {
+ cookiesEnabled: false,
+ bidSize: 32,
+ storage: localStorage
+ },
+ user: {
+ accountBasePath: currentConfig.value.accountBasePath,
+ storage: localStorage,
+ },
+ axios: {
+ baseURL: currentConfig.value.apiUrl,
+ tokenHeader: import.meta.env.VITE_WEB_TOKEN_HEADER,
+ }
+ })
+ }
+
+ const saveConfig = async (config: PluginConfig) : Promise<void> => {
+ await storage.local.set({ siteConfig: config });
+ }
+
+ const onGetSiteConfig = async ({ } :BridgeMessage<any>): Promise<PluginConfig> => {
+ return { ...currentConfig.value }
+ }
+
+ const onSetSitConfig = async ({ sender, data }: BridgeMessage<PluginConfig>) : Promise<void> => {
+ //Config messages should only come from the options page
+ if (sender.context !== 'options') {
+ throw new Error('Unauthorized');
+ }
+
+ //Save the config
+ await saveConfig(data);
+
+ //Restore the api settings
+ restoreApiSettings();
+
+ debugLog('Config settings saved!');
+ }
+
+ runtime.onInstalled.addListener(() => {
+ restoreApiSettings();
+ debugLog('Server settings restored from storage');
+ });
+
+ runtime.onConnect.addListener(async () => {
+ //refresh the config on connect
+ currentConfig.value = await getCurrentConfig();
+ })
+
+ return () =>{
+ return{
+ getCurrentConfig,
+ restoreApiSettings,
+ saveConfig,
+ currentConfig:readonly(currentConfig),
+ onGetSiteConfig,
+ onSetSitConfig
+ }
+ }
+})() \ No newline at end of file
diff --git a/extension/src/entries/background/types.ts b/extension/src/entries/background/types.ts
new file mode 100644
index 0000000..d459ea1
--- /dev/null
+++ b/extension/src/entries/background/types.ts
@@ -0,0 +1,76 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { JsonObject } from "type-fest";
+
+export interface NostrPubKey extends JsonObject {
+ readonly Id: string,
+ readonly UserName: string,
+ readonly PublicKey: string,
+ readonly Created: string,
+ readonly LastModified: string
+}
+
+export interface NostrEvent extends JsonObject {
+ KeyId: string,
+ readonly id: string,
+ readonly pubkey: string,
+ readonly content: string,
+}
+
+export interface EventMessage extends JsonObject {
+ readonly event: NostrEvent
+}
+
+export interface NostrRelay extends JsonObject {
+ readonly Id: string,
+ readonly url: string,
+ readonly flags: number,
+ readonly Created: string,
+ readonly LastModified: string
+}
+
+export interface LoginMessage extends JsonObject {
+ readonly token: string
+}
+
+export interface ClientStatus {
+ readonly loggedIn: boolean;
+ readonly userName: string | null;
+ readonly darkMode: boolean;
+}
+
+export enum NostrRelayFlags {
+ None = 0,
+ Default = 1,
+ Preferred = 2,
+}
+
+export enum NostrRelayMessageType{
+ updateRelay = 1,
+ addRelay = 2,
+ deleteRelay = 3
+}
+
+export interface NostrRelayMessage extends JsonObject {
+ readonly relay: NostrRelay
+ readonly type: NostrRelayMessageType
+}
+
+export interface LoginMessage extends JsonObject {
+ readonly token: string
+ readonly username: string
+ readonly password: string
+} \ No newline at end of file
diff --git a/extension/src/entries/contentScript/nostr-shim.js b/extension/src/entries/contentScript/nostr-shim.js
new file mode 100644
index 0000000..26b17a9
--- /dev/null
+++ b/extension/src/entries/contentScript/nostr-shim.js
@@ -0,0 +1,87 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import { runtime } from "webextension-polyfill";
+import { isEqual, isNil, isEmpty } from 'lodash'
+import { sendMessage } from 'webext-bridge/content-script'
+import { apiCall } from '@vnuge/vnlib.browser'
+import { useManagment } from './../../bg-api/content-script'
+
+const { getSiteConfig } = useManagment()
+const nip07Enabled = () => getSiteConfig().then(p => p.autoInject);
+
+//Setup listener for the content script to process nostr messages
+
+const ext = '@vnuge/nvault-extension'
+
+let _promptHandler = () => {}
+
+export const usePrompt = (callback) => {
+ //Register the callback
+ _promptHandler = async (event) => {
+ return new Promise((resolve, reject) => {
+ callback(event).then(resolve).catch(reject)
+ })
+ }
+ return {}
+}
+
+//Only inject the script if the site has autoInject enabled
+nip07Enabled().then(enabled => {
+ console.log('Nip07 enabled:', enabled)
+ if (enabled) {
+ // inject the script that will provide window.nostr
+ let script = document.createElement('script');
+ script.setAttribute('async', 'false');
+ script.setAttribute('type', 'text/javascript');
+ script.setAttribute('src', runtime.getURL('src/entries/nostr-provider.js'));
+ document.head.appendChild(script);
+
+ //Only listen for messages if injection is enabled
+ window.addEventListener('message', async ({ source, data, origin }) => {
+ //Confirm the message format is correct
+ if (!isEqual(source, window) || isEmpty(data) || isNil(data.type)) {
+ return
+ }
+ //Confirm extension is for us
+ if (!isEqual(data.ext, ext)) {
+ return
+ }
+
+ // pass on to background
+ var response;
+ await apiCall(async () => {
+ switch (data.type) {
+ case 'getPublicKey':
+ case 'signEvent':
+ //Check the public key against selected key
+ case 'getRelays':
+ case 'nip04.encrypt':
+ case 'nip04.decrypt':
+ //await propmt for user to allow the request
+ const allow = await _promptHandler({ ...data, origin })
+ //send request to background
+ response = allow ? await sendMessage(data.type, { ...data.payload, origin }) : { error: 'User denied permission' }
+ break;
+ default:
+ throw new Error('Unknown nostr message type')
+ }
+ })
+ // return response message, must have the same id as the request
+ window.postMessage({ ext, id: data.id, response }, origin);
+ });
+ }
+})
diff --git a/extension/src/entries/contentScript/primary/App.vue b/extension/src/entries/contentScript/primary/App.vue
new file mode 100644
index 0000000..05dfac0
--- /dev/null
+++ b/extension/src/entries/contentScript/primary/App.vue
@@ -0,0 +1,17 @@
+<template>
+ <html>
+ <body id="injected-root">
+ <notifications class="toaster" group="form" position="top-right" />
+ <Prompt></Prompt>
+ </body>
+ </html>
+</template>
+
+<script setup lang="ts">
+import { configureNotifier } from '@vnuge/vnlib.browser';
+import { notify } from "@kyvg/vue3-notification";
+import Prompt from './components/PromptPopup.vue'
+
+configureNotifier({ notify, close: notify.close })
+
+</script> \ No newline at end of file
diff --git a/extension/src/entries/contentScript/primary/components/PromptPopup.vue b/extension/src/entries/contentScript/primary/components/PromptPopup.vue
new file mode 100644
index 0000000..057f66a
--- /dev/null
+++ b/extension/src/entries/contentScript/primary/components/PromptPopup.vue
@@ -0,0 +1,148 @@
+<template>
+ <div v-show="isOpen" id="nvault-ext-prompt">
+ <div class="relative text-white" style="z-index:9147483647 !important" ref="prompt">
+ <div class="fixed inset-0 left-0 flex justify-center w-full h-full p-4 bg-black/50">
+ <div class="relative w-full max-w-md mx-auto mt-20 mb-auto">
+ <div class="w-full p-4 border rounded-lg shadow-lg bg-dark-700 border-dark-400">
+ <div v-if="loggedIn" class="">
+ <h3 class="">Allow access</h3>
+ <div class="pl-1 text-sm">
+ Identity:
+ </div>
+ <div class="p-2 mt-1 text-center border rounded border-dark-400 bg-dark-600">
+ <div :class="[selectedKey?.UserName ? '' : 'text-red-500']">
+ {{ selectedKey?.UserName ?? 'Select Identity' }}
+ </div>
+ </div>
+ <div class="mt-5 text-center">
+ <span class="text-primary-500">{{ site }}</span>
+ would like to access to
+ <span class="text-yellow-500">{{ event.msg }}</span>
+ </div>
+ <div class="flex gap-2 mt-4">
+ <div class="">
+ <Popover class="relative">
+ <PopoverButton class="rounded btn sm">View Raw</PopoverButton>
+ <PopoverPanel class="absolute z-10">
+ <div class="min-w-[22rem] p-2 border rounded bg-dark-700 border-dark-400 shadow-md text-sm">
+ <p class="pl-1">
+ Event Data:
+ </p>
+ <div class="p-2 mt-1 text-left border rounded border-dark-400 bg-dark-600 overflow-y-auto max-h-[22rem]">
+<pre>
+{{ evData }}
+</pre>
+ </div>
+ </div>
+ </PopoverPanel>
+ </Popover>
+ </div>
+ <div class="ml-auto">
+ <button :disabled="selectedKey?.Id == undefined" class="rounded btn primary sm" @click="allow">Allow</button>
+ </div>
+ <div>
+ <button class="rounded btn sm red" @click="close">Close</button>
+ </div>
+ </div>
+ </div>
+ <div v-else class="">
+ <h3 class="">Log in!</h3>
+ <div class="">
+ You must log in before you can allow access.
+ </div>
+ <div class="flex justify-end gap-2 mt-4">
+ <div>
+ <button class="rounded btn sm red" @click="close">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { usePrompt } from '~/entries/contentScript/nostr-shim'
+import { computed } from '@vue/reactivity';
+import { onClickOutside } from '@vueuse/core';
+import { useStatus } from '~/bg-api/content-script.ts';
+import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
+import { first } from 'lodash';
+
+const { loggedIn, selectedKey } = useStatus()
+
+const prompt = ref(null)
+
+interface PopupEvent{
+ type: string
+ msg: string
+ origin: string
+ data: any
+ allow: () => void
+ close: () => void
+}
+
+const evStack = ref<PopupEvent[]>([])
+const isOpen = computed(() => evStack.value.length > 0)
+const event = computed<PopupEvent | undefined>(() => first(evStack.value));
+
+const site = computed(() => new URL(event.value?.origin || "https://example.com").host)
+const evData = computed(() => JSON.stringify(event.value || {}, null, 2))
+
+
+const close = () => {
+ //Pop the first event off
+ const res = evStack.value.shift()
+ res?.close()
+}
+const allow = () => {
+ //Pop the first event off
+ const res = evStack.value.shift()
+ res?.allow()
+}
+
+//Setup click outside
+//onClickOutside(prompt, () => isOpen.value ? close() : null)
+
+//Listen for events
+usePrompt(async (ev: PopupEvent) => {
+
+ console.log('usePrompt', ev)
+
+ switch(ev.type){
+ case 'getPublicKey':
+ ev.msg = "your public key"
+ break;
+ case 'signEvent':
+ ev.msg = "sign an event"
+ break;
+ case 'getRelays':
+ ev.msg = "get your preferred relays"
+ break;
+ case 'nip04.encrypt':
+ ev.msg = "encrypt data"
+ break;
+ case 'nip04.decrypt':
+ ev.msg = "decrypt data"
+ break;
+ }
+
+ return new Promise((resolve, reject) => {
+ evStack.value.push({
+ ...ev,
+ allow: () => resolve(true),
+ close: () => resolve(false),
+ })
+ })
+})
+
+
+</script>
+
+<style lang="scss">
+
+
+</style>
diff --git a/extension/src/entries/contentScript/primary/main.js b/extension/src/entries/contentScript/primary/main.js
new file mode 100644
index 0000000..24ef4ef
--- /dev/null
+++ b/extension/src/entries/contentScript/primary/main.js
@@ -0,0 +1,41 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import { createApp } from "vue";
+import renderContent from "../renderContent";
+import App from "./App.vue";
+import Notification from '@kyvg/vue3-notification'
+import '@fontsource/noto-sans-masaram-gondi'
+
+//We need inline styles to inject into the shadow dom
+import tw from "~/assets/tailwind.scss?inline";
+import localStyle from './style.scss?inline'
+
+renderContent([], (appRoot, shadowRoot) => {
+ createApp(App)
+ .use(Notification)
+ .mount(appRoot);
+
+ //Add tailwind styles just to the shadow dom element
+ const style = document.createElement('style')
+ style.innerHTML = tw.toString()
+ shadowRoot.appendChild(style)
+
+ //Add local styles
+ const style2 = document.createElement('style')
+ style2.innerHTML = localStyle.toString()
+ shadowRoot.appendChild(style2)
+}); \ No newline at end of file
diff --git a/extension/src/entries/contentScript/primary/style.scss b/extension/src/entries/contentScript/primary/style.scss
new file mode 100644
index 0000000..bcdbbfd
--- /dev/null
+++ b/extension/src/entries/contentScript/primary/style.scss
@@ -0,0 +1,15 @@
+
+#injected-root{
+
+ .toaster{
+ @apply fixed top-10 right-2 z-[999999999] max-w-[250px];
+ }
+
+ .vue-notification-template.vue-notification.error{
+ @apply bg-red-500 text-white px-4 py-2;
+
+ .notification-title{
+
+ }
+ }
+} \ No newline at end of file
diff --git a/extension/src/entries/contentScript/renderContent.js b/extension/src/entries/contentScript/renderContent.js
new file mode 100644
index 0000000..84c5b9f
--- /dev/null
+++ b/extension/src/entries/contentScript/renderContent.js
@@ -0,0 +1,48 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import {runtime} from "webextension-polyfill";
+
+export default async function renderContent(
+ cssPaths,
+ render = (_appRoot) => {}
+) {
+ const appContainer = document.createElement("div");
+ const shadowRoot = appContainer.attachShadow({
+ mode: import.meta.env.DEV ? "open" : "closed",
+ });
+ const appRoot = document.createElement("div");
+
+ if (import.meta.hot) {
+ const { addViteStyleTarget } = await import(
+ "@samrum/vite-plugin-web-extension/client"
+ );
+
+ await addViteStyleTarget(shadowRoot);
+ } else {
+ cssPaths.forEach((cssPath) => {
+ const styleEl = document.createElement("link");
+ styleEl.setAttribute("rel", "stylesheet");
+ styleEl.setAttribute("href", runtime.getURL(cssPath));
+ shadowRoot.appendChild(styleEl);
+ });
+ }
+
+ shadowRoot.appendChild(appRoot);
+ document.body.appendChild(appContainer);
+
+ render(appRoot, shadowRoot);
+} \ No newline at end of file
diff --git a/extension/src/entries/nostr-provider.js b/extension/src/entries/nostr-provider.js
new file mode 100644
index 0000000..1b8807f
--- /dev/null
+++ b/extension/src/entries/nostr-provider.js
@@ -0,0 +1,92 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+const waiting = new Map();
+
+const ext = '@vnuge/nvault-extension'
+
+const debugLog = (...args) => {
+ console.log(`[${ext}]`, ...args)
+}
+
+const sendMessage = (type, payload) => new Promise((resolve, reject) => {
+ const id = Math.random().toString(36);
+ waiting.set(id, { resolve, reject });
+ window.postMessage({ type, payload, id, ext }, '*');
+});
+
+/**
+ * Listen for messages from the content script
+ */
+window.addEventListener('message', ({ data }) => {
+
+ //Confirm the message format is correct
+ if (!data || !data.response || data.ext !== ext || !waiting.get(data.id)){
+ return;
+ }
+
+ debugLog(data)
+
+ //Explode now valid
+ const { response, id } = data;
+
+ const { resolve, reject } = waiting.get(id);
+
+ if (response.error) {
+
+ //Construct an error object from the resopnse message
+ const errorMessage = response.error.message ?? response.error;
+
+ let error = new Error(`${ext}: ${errorMessage}`);
+ error.stack = response.error.stack;
+
+ //Reject the promise as error
+ reject(error);
+
+ } else {
+ //Resolve the promise as success
+ resolve(response);
+ }
+
+ //Remove the waiter from the list
+ waiting.delete(id)
+});
+
+
+//Expose the Nostr API to the window object
+window.nostr = {
+
+ //Redirect calls to the background script
+ async getPublicKey(){
+ const { PublicKey } = await sendMessage('getPublicKey', {})
+ return PublicKey
+ } ,
+
+ async signEvent(event){
+ const { event:ev } = await sendMessage('signEvent', { event })
+ debugLog("Signed event", ev);
+ return ev
+ },
+
+ async getRelays(){
+ const { relays } = await sendMessage('getRelays', {})
+ return relays
+ },
+
+ nip04: {
+ encrypt: (peer, plaintext) => sendMessage('nip04.encrypt', { peer, plaintext }),
+ decrypt: (peer, ciphertext) => sendMessage('nip04.decrypt', { peer, ciphertext }),
+ },
+}; \ No newline at end of file
diff --git a/extension/src/entries/options/App.vue b/extension/src/entries/options/App.vue
new file mode 100644
index 0000000..d44a4ff
--- /dev/null
+++ b/extension/src/entries/options/App.vue
@@ -0,0 +1,199 @@
+<template>
+ <main id="injected-root">
+
+ <notifications class="toaster" group="form" position="top-right" />
+
+ <div class="container flex w-full p-4 mx-auto mt-8 text-gray-800 dark:text-gray-200">
+ <div class="w-full max-w-4xl mx-auto">
+ <div class="">
+ <h3>Nostr Vault</h3>
+ </div>
+ <TabGroup :selected-index="selectedTab" @change="id => selectedTab = id" >
+ <TabList class="flex gap-3 pb-2 border-b border-gray-300 dark:border-dark-500">
+ <Tab v-slot="{ selected }">
+ <button class="border-b-2" :class="[selected ? 'border-primary-500' : 'border-transparent']">
+ Identities
+ </button>
+ </Tab>
+ <Tab v-slot="{ selected }">
+ <button class="border-b-2" :class="[selected ? 'border-primary-500' : 'border-transparent']">
+ Privacy
+ </button>
+ </Tab>
+ <Tab v-slot="{ selected }">
+ <button class="border-b-2" :class="[selected ? 'border-primary-500' : 'border-transparent']">
+ Settings
+ </button>
+ </Tab>
+ <Tab>
+ <!-- Hidden for editing -->
+ </Tab>
+ <div class="m-auto">
+ <div class="">
+ <!-- Add spinner -->
+
+ </div>
+ </div>
+ <div class="hidden my-auto text-sm font-semibold sm:block">
+ <div v-if="userName">
+ {{ userName }}
+ </div>
+ <div v-else>
+ <div>
+ Sign In
+ </div>
+ </div>
+ </div>
+ <div class="ml-auto sm:ml-0">
+ <button class="rounded btn xs" @click="toggleDark()" >
+ <fa-icon v-if="darkMode" icon="sun"/>
+ <fa-icon v-else icon="moon" />
+ </button>
+ </div>
+ </TabList>
+ <TabPanels>
+ <TabPanel class="mt-4">
+ <Identities :all-keys="allKeys" @edit-key="editKey" @update-all="reloadKeys"/>
+ </TabPanel>
+ <TabPanel>
+ <Privacy/>
+ </TabPanel>
+ <TabPanel>
+ <SiteSettings/>
+ </TabPanel>
+ <TabPanel>
+ <div class="flex flex-col px-2 mt-4">
+ <div class="absolute mx-auto">
+ <h4>Edit Identity</h4>
+ </div>
+ <div class="ml-auto">
+ <button class="rounded btn sm" @click.self="doneEditing">
+ <fa-icon class="mr-2" icon="chevron-left"/>
+ Back
+ </button>
+ </div>
+ <div class="flex flex-col mx-auto mt-2">
+ <div class="text-sm break-all">
+ Internal Id : {{ keyBuffer?.Id }}
+ </div>
+ <div class="text-sm break-all">
+ Public Key : {{ keyBuffer?.PublicKey }}
+ </div>
+ <div class="flex flex-col w-full max-w-md mx-auto mt-3">
+ <div class="">
+ <div class="text-sm">User Name</div>
+ <input class="w-full primary" type="text" v-model="keyBuffer.UserName"/>
+ </div>
+ <div class="gap-2 my-3 ml-auto">
+ <button class="rounded btn sm primary" @click="onUpdate">Update</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </TabPanel>
+ </TabPanels>
+ </TabGroup>
+ </div>
+ </div>
+ </main>
+</template>
+
+<script setup lang="ts">
+import { ref, watchEffect } from "vue";
+import {
+ TabGroup,
+ TabList,
+ Tab,
+ TabPanels,
+ TabPanel,
+} from '@headlessui/vue'
+import { configureNotifier } from '@vnuge/vnlib.browser';
+import { useManagment, useStatus, NostrPubKey } from '~/bg-api/options.ts';
+import { notify } from "@kyvg/vue3-notification";
+import { watchDebounced } from '@vueuse/core';
+import SiteSettings from './components/SiteSettings.vue';
+import Identities from './components/Identities.vue';
+import Privacy from "./components/Privacy.vue";
+
+//Configure the notifier to use the notification library
+configureNotifier({ notify, close: notify.close })
+
+const { userName, darkMode } = useStatus()
+const { getAllKeys, updateIdentity, getSiteConfig, saveSiteConfig } = useManagment()
+
+const selectedTab = ref(0)
+const allKeys = ref([])
+const keyBuffer = ref(null)
+
+const editKey = (key: NostrPubKey) =>{
+ //Goto hidden tab
+ selectedTab.value = 3
+ //Set selected key
+ keyBuffer.value = { ...key }
+}
+
+const doneEditing = () =>{
+ //Goto hidden tab
+ selectedTab.value = 0
+ //Set selected key
+ keyBuffer.value = null
+}
+
+const onUpdate = async () =>{
+ //Update identity
+ await updateIdentity(keyBuffer.value)
+ //Goto hidden tab
+ selectedTab.value = 0
+ //Set selected key
+ keyBuffer.value = null
+}
+
+const reloadKeys = async () =>{
+ //Load all keys (identities)
+ const keys = await getAllKeys()
+ allKeys.value = keys;
+}
+
+const toggleDark = async () => {
+ const config = await getSiteConfig();
+ config.darkMode = !config.darkMode;
+ await saveSiteConfig(config);
+}
+
+//Initial load
+reloadKeys();
+
+//If the tab changes to the identities tab, reload the keys
+watchDebounced(selectedTab, id => id == 0 ? reloadKeys() : null, { debounce: 100 })
+
+//Watch for dark mode changes and update the body class
+watchEffect(() => darkMode.value ? document.body.classList.add('dark') : document.body.classList.remove('dark'));
+
+</script>
+
+<style lang="scss" scoped>
+
+main {
+ font-family: Avenir, Helvetica, Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.toaster{
+ position: fixed;
+ top: 15px;
+ right: 0;
+ z-index: 9999;
+ max-width: 230px;
+}
+
+.id-card{
+ @apply flex md:flex-row flex-col gap-2 p-3 text-sm duration-75 ease-in-out border-2 rounded-lg shadow-md cursor-pointer;
+ @apply bg-white dark:bg-dark-700 border-gray-200 hover:border-gray-400 dark:border-dark-500 hover:dark:border-dark-200;
+
+ &.selected{
+ @apply border-primary-500 hover:border-primary-500;
+ }
+}
+
+</style> \ No newline at end of file
diff --git a/extension/src/entries/options/components/Identities.vue b/extension/src/entries/options/components/Identities.vue
new file mode 100644
index 0000000..c86a6ac
--- /dev/null
+++ b/extension/src/entries/options/components/Identities.vue
@@ -0,0 +1,188 @@
+<template>
+ <div class="sm:px-3">
+ <div class="flex justify-end gap-2">
+ <div class="">
+ <div class="">
+ <button class="rounded btn sm" @click="onNip05Download">
+ NIP-05
+ <fa-icon icon="download" class="ml-1" />
+ </button>
+ </div>
+ </div>
+ <div class="mb-2">
+ <Popover class="relative" v-slot="{ open }">
+ <PopoverButton class="rounded btn primary sm">Create</PopoverButton>
+ <PopoverOverlay v-if="open" class="fixed inset-0 bg-black opacity-30" />
+ <PopoverPanel class="absolute z-10 mt-2 md:-left-12" v-slot="{ close }">
+ <div class="p-4 bg-white border border-gray-200 rounded-md shadow-lg dark:border-dark-300 dark:bg-dark-700">
+ <div class="text-sm w-72">
+ <form @submit.prevent="e => onCreate(e, close)">
+ Create new nostr identity
+ <div class="mt-2">
+ <input class="w-full primary" type="text" name="username" placeholder="User Name"/>
+ </div>
+ <div class="mt-2">
+ <input class="w-full primary" type="text" name="key" placeholder="Existing key?"/>
+ <div class="p-1.5 text-xs text-gray-600 dark:text-gray-300">
+ Optional, hexadecimal private key (64 characters)
+ </div>
+ </div>
+ <div class="flex justify-end mt-2">
+ <button class="rounded btn sm primary" type="submit">Create</button>
+ </div>
+ </form>
+ </div>
+ </div>
+ </PopoverPanel>
+ </Popover>
+ </div>
+ </div>
+ <div v-for="key in allKeys" :key="key" class="mt-2 mb-3">
+ <div class="id-card" :class="{'selected': isSelected(key)}" @click.self="selectKey(key)">
+
+ <div class="flex flex-col min-w-0" @click="selectKey(key)">
+ <div class="py-2">
+
+ <table class="w-full text-sm text-left border-collapse">
+ <thead class="">
+ <tr>
+ <th scope="col" class="p-2 font-medium">Nip 05</th>
+ <th scope="col" class="p-2 font-medium">Modified</th>
+ <th scope="col" class="p-2 font-medium"></th>
+ </tr>
+ </thead>
+ <tbody class="border-t border-gray-100 divide-y divide-gray-100 dark:border-dark-500 dark:divide-dark-500">
+ <tr>
+ <th class="p-2 font-medium">{{ key.UserName }}</th>
+ <td class="p-2">{{ prettyPrintDate(key) }}</td>
+ <td class="flex justify-end p-2 ml-auto text-sm font-medium">
+ <div class="ml-auto button-group">
+ <button class="btn sm borderless" @click="copy(key.PublicKey)">
+ <fa-icon icon="copy"/>
+ </button>
+ <button class="btn sm borderless" @click="editKey(key)">
+ <fa-icon icon="edit"/>
+ </button>
+ <button class="btn sm red borderless" @click="onDeleteKey(key)">
+ <fa-icon icon="trash" />
+ </button>
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ </div>
+ <div class="py-2 overflow-hidden border-gray-500 border-y dark:border-dark-500 text-ellipsis">
+ <span class="font-semibold">pub:</span>
+ <span class="ml-1">{{ key.PublicKey }}</span>
+ </div>
+ <div class="py-2">
+ <strong>Id:</strong> {{ key.Id }}
+ </div>
+ </div>
+ </div>
+ </div>
+ <a class="hidden" ref="downloadAnchor"></a>
+ </div>
+</template>
+
+<script setup lang="ts">
+
+import { isEqual, map } from 'lodash'
+import { ref, toRefs } from "vue";
+import {
+ Popover,
+ PopoverButton,
+ PopoverPanel
+} from '@headlessui/vue'
+import { apiCall, configureNotifier } from '@vnuge/vnlib.browser';
+import { useManagment, useStatus } from '~/bg-api/options.ts';
+import { notify } from "@kyvg/vue3-notification";
+import { useClipboard } from '@vueuse/core';
+import { NostrIdentiy } from '~/bg-api/bg-api';
+import { NostrPubKey } from '../../background/types';
+
+const emit = defineEmits(['edit-key', 'update-all'])
+const props = defineProps<{
+ allKeys:NostrIdentiy[]
+}>()
+
+const { allKeys } = toRefs(props)
+
+//Configre the notifier to use the toaster
+configureNotifier({ notify, close: notify.close })
+
+const downloadAnchor = ref<HTMLAnchorElement>()
+const { selectedKey } = useStatus()
+const { selectKey, createIdentity, deleteIdentity, getAllKeys } = useManagment()
+const { copy } = useClipboard()
+
+const isSelected = (me : NostrIdentiy) => isEqual(me, selectedKey.value)
+
+const editKey = (key : NostrIdentiy) => emit('edit-key', key);
+
+const onCreate = async (e: Event, onClose : () => void) => {
+
+ //get username input from event
+ const UserName = e.target['username']?.value as string
+ //try to get existing key field
+ const ExistingKey = e.target['key']?.value as string
+
+ //Create new identity
+ await createIdentity({ UserName, ExistingKey })
+ //Update keys
+ emit('update-all');
+ onClose()
+}
+
+const prettyPrintDate = (key : NostrIdentiy) => {
+ const d = new Date(key.LastModified)
+ return `${d.toLocaleDateString()} ${d.toLocaleTimeString()}`
+}
+
+const onDeleteKey = async (key : NostrIdentiy) => {
+
+ if(!confirm(`Are you sure you want to delete ${key.UserName}?`)){
+ return;
+ }
+
+ //Delete identity
+ await deleteIdentity(key)
+
+ //Update keys
+ emit('update-all');
+}
+
+const onNip05Download = () => {
+ apiCall(async () => {
+ //Get all public keys from the server
+ const keys = await getAllKeys() as NostrPubKey[]
+ const nip05 = {}
+ //Map the keys to the NIP-05 format
+ map(keys, k => nip05[k.UserName] = k.PublicKey)
+ //create file blob
+ const blob = new Blob([JSON.stringify({ names:nip05 })], { type: 'application/json' })
+
+ //Download the file
+ downloadAnchor.value!.href = URL.createObjectURL(blob);
+ downloadAnchor.value?.setAttribute('download', 'nostr.json')
+ downloadAnchor.value?.click();
+
+ })
+}
+
+</script>
+
+<style scoped lang="scss">
+
+.id-card{
+ @apply flex md:flex-row flex-col gap-2 p-3 px-12 text-sm duration-75 ease-in-out border-2 rounded-lg shadow-md cursor-pointer w-fit mx-auto;
+ @apply bg-white dark:bg-dark-800 border-gray-200 hover:border-gray-400 dark:border-dark-500 hover:dark:border-dark-200;
+
+ &.selected{
+ @apply border-primary-500 hover:border-primary-500;
+ }
+}
+
+</style> \ No newline at end of file
diff --git a/extension/src/entries/options/components/Privacy.vue b/extension/src/entries/options/components/Privacy.vue
new file mode 100644
index 0000000..7d2ce4d
--- /dev/null
+++ b/extension/src/entries/options/components/Privacy.vue
@@ -0,0 +1,9 @@
+<template>
+ <div class="flex flex-col w-full mt-4 sm:px-2">
+
+ </div>
+</template>
+
+<script setup lang="ts">
+
+</script> \ No newline at end of file
diff --git a/extension/src/entries/options/components/SiteSettings.vue b/extension/src/entries/options/components/SiteSettings.vue
new file mode 100644
index 0000000..eafe8f3
--- /dev/null
+++ b/extension/src/entries/options/components/SiteSettings.vue
@@ -0,0 +1,235 @@
+<template>
+ <div class="flex flex-col w-full mt-4 sm:px-2">
+
+ <form @submit.prevent="">
+ <div class="w-full max-w-md mx-auto">
+ <h3 class="text-center">
+ Extension settings
+ </h3>
+ <div class="my-6">
+ <fieldset :disabled="waiting">
+ <div class="w-full">
+ <div class="flex flex-row justify-between">
+ <label class="mr-2">Always on NIP-07</label>
+ <Switch
+ v-model="buffer.autoInject"
+ :class="buffer.autoInject ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'"
+ class="relative inline-flex items-center h-6 ml-auto rounded-full w-11"
+ >
+ <span class="sr-only">NIP-07</span>
+ <span
+ :class="buffer.autoInject ? 'translate-x-6' : 'translate-x-1'"
+ class="inline-block w-4 h-4 transition transform bg-white rounded-full"
+ />
+ </Switch>
+ </div>
+ </div>
+ <p class="mt-1 text-xs">
+ Enable auto injection of <code>window.nostr</code> support to all websites. Sites may be able to
+ track you if you enable this feature.
+ </p>
+ </fieldset>
+ </div>
+ <h3 class="text-center">
+ Server settings
+ </h3>
+ <p class="text-sm">
+ You must be careful when editing these settings as you may loose connection to your vault
+ server if you input the wrong values.
+ </p>
+ <div class="flex justify-end mt-2">
+ <div class="button-group">
+ <button class="rounded btn sm" @click="toggleEdit()">
+ <fa-icon v-if="editMode" icon="lock-open"/>
+ <fa-icon v-else icon="lock"/>
+ </button>
+ <a :href="data.apiUrl" target="_blank">
+ <button type="button" class="rounded btn sm">
+ <fa-icon icon="external-link-alt"/>
+ </button>
+ </a>
+ </div>
+ </div>
+ <fieldset :disabled="waiting || !editMode">
+ <div class="pl-1 mt-2">
+ <div class="flex flex-row w-full">
+ <div>
+ <label class="mb-2">Stay logged in</label>
+ <Switch
+ v-model="v$.heartbeat.$model"
+ :class="v$.heartbeat.$model ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'"
+ class="relative inline-flex items-center h-6 mx-auto rounded-full w-11"
+ >
+ <span class="sr-only">Stay logged in</span>
+ <span
+ :class="v$.heartbeat.$model ? 'translate-x-6' : 'translate-x-1'"
+ class="inline-block w-4 h-4 transition transform bg-white rounded-full"
+ />
+ </Switch>
+ </div>
+ <div class="my-auto text-xs">
+ Enables keepalive messages to regenerate credentials when they expire
+ </div>
+ </div>
+ </div>
+ <div class="mt-2">
+ <label class="pl-1">BaseUrl</label>
+ <input class="w-full primary" v-model="v$.apiUrl.$model" :class="{'error': v$.apiUrl.$invalid }" />
+ <p class="pl-1 mt-1 text-xs text-red-500">
+ * The http path to the vault server (must start with http:// or https://)
+ </p>
+ </div>
+ <div class="mt-2">
+ <label class="pl-1">Account endpoint</label>
+ <input class="w-full primary" v-model="v$.accountBasePath.$model" :class="{ 'error': v$.accountBasePath.$invalid }" />
+ <p class="pl-1 mt-1 text-xs text-red-500">
+ * This is the path to the account server endpoint (must start with /)
+ </p>
+ </div>
+ <div class="mt-2">
+ <label class="pl-1">Nostr endpoint</label>
+ <input class="w-full primary" v-model="v$.nostrEndpoint.$model" :class="{ 'error': v$.nostrEndpoint.$invalid }" />
+ <p class="pl-1 mt-1 text-xs text-red-500">
+ * This is the path to the Nostr plugin endpoint path (must start with /)
+ </p>
+ </div>
+ </fieldset>
+ <div class="flex justify-end mt-2">
+ <button :disabled="!modified || waiting" class="rounded btn sm" :class="{'primary':modified}" @click="onSave">Save</button>
+ </div>
+ </div>
+ </form>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { apiCall, useDataBuffer, useFormToaster, useVuelidateWrapper, useWait } from '@vnuge/vnlib.browser';
+import { computed, ref, watch } from 'vue';
+import { useManagment } from '~/bg-api/options.ts';
+import { useToggle, watchDebounced } from '@vueuse/core';
+import { maxLength, helpers, required } from '@vuelidate/validators'
+import { clone, isNil } from 'lodash';
+import{ Switch } from '@headlessui/vue'
+import useVuelidate from '@vuelidate/core'
+
+const { waiting } = useWait();
+const form = useFormToaster();
+const { getSiteConfig, saveSiteConfig } = useManagment();
+
+const { apply, data, buffer, modified } = useDataBuffer({
+ apiUrl: '',
+ accountBasePath: '',
+ nostrEndpoint:'',
+ heartbeat:false,
+ autoInject:true,
+})
+
+const url = (val : string) => /^https?:\/\/[a-zA-Z0-9\.\:\/-]+$/.test(val);
+const path = (val : string) => /^\/[a-zA-Z0-9-_]+$/.test(val);
+
+const vRules = {
+ apiUrl: {
+ required:helpers.withMessage('Base url is required', required),
+ maxLength: helpers.withMessage('Base url must be less than 100 characters', maxLength(100)),
+ url: helpers.withMessage('You must input a valid url', url)
+ },
+ accountBasePath: {
+ required:helpers.withMessage('Account path is required', required),
+ maxLength: maxLength(50),
+ alphaNum: helpers.withMessage('Account path is not a valid endpoint path that begins with /', path)
+ },
+ nostrEndpoint:{
+ required: helpers.withMessage('Nostr path is required', required),
+ maxLength: maxLength(50),
+ alphaNum: helpers.withMessage('Nostr path is not a valid endpoint path that begins with /', path)
+ },
+ heartbeat: {},
+ darkMode:{}
+}
+
+//Configure validator and validate function
+const v$ = useVuelidate(vRules, buffer)
+const { validate } = useVuelidateWrapper(v$);
+
+const editMode = ref(false);
+const toggleEdit = useToggle(editMode);
+
+const autoInject = computed(() => buffer.autoInject)
+
+const onSave = async () => {
+
+ //Validate
+ const result = await validate();
+ if(!result){
+ return;
+ }
+
+ //Test connection to the server
+ if(await testConnection() !== true){
+ return;
+ }
+
+ form.info({
+ title: 'Reloading in 4 seconds',
+ text: 'Your configuration will be saved and the extension will reload in 4 seconds'
+ })
+
+ await new Promise(r => setTimeout(r, 4000));
+
+ publishConfig();
+
+ //disable dit
+ toggleEdit();
+}
+
+const publishConfig = async () =>{
+ const c = clone(buffer);
+ await saveSiteConfig(c);
+ await loadConfig();
+}
+
+const testConnection = async () =>{
+ return await apiCall(async ({axios, toaster}) =>{
+ try{
+ await axios.get(`${buffer.apiUrl}`);
+ toaster.general.success({
+ title: 'Success',
+ text: 'Succcesfully connected to the vault server'
+ });
+ return true;
+ }
+ catch(e){
+ if(isNil(e.response?.status)){
+ toaster.form.error({
+ title: 'Network error',
+ text: `Please verify your vault server address`
+ });
+ }
+
+ toaster.form.error({
+ title: 'Warning',
+ text: `Failed to connect to the vault server. Status code: ${e.response.status}`
+ });
+ }
+ })
+}
+
+const loadConfig = async () => {
+ const config = await getSiteConfig();
+ apply(config);
+
+ //Watch for changes to autoinject value and publish changes when it does
+ watchDebounced(autoInject, publishConfig, { debounce: 500 })
+}
+
+//If edit mode is toggled off, reload config
+watch(editMode, v => v ? null : loadConfig());
+
+
+loadConfig();
+
+</script>
+
+<style lang="scss">
+
+</style> \ No newline at end of file
diff --git a/extension/src/entries/options/index.html b/extension/src/entries/options/index.html
new file mode 100644
index 0000000..72f2de7
--- /dev/null
+++ b/extension/src/entries/options/index.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html lang="en" class="flex" style="min-height: 100vh; min-width: 100vw;">
+ <head>
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>Nostr Vault</title>
+ <style>
+ body.dark{
+ @apply bg-dark-900;
+ }
+ body{
+ @apply bg-gray-50;
+ }
+ </style>
+ </head>
+ <body class="w-full">
+ <div id="app"></div>
+ <script type="module" src="./main.js"></script>
+ </body>
+</html>
diff --git a/extension/src/entries/options/main.js b/extension/src/entries/options/main.js
new file mode 100644
index 0000000..92a4868
--- /dev/null
+++ b/extension/src/entries/options/main.js
@@ -0,0 +1,33 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import { createApp } from "vue";
+import App from "./App.vue";
+import '@fontsource/noto-sans-masaram-gondi'
+import "~/assets/tailwind.scss";
+import Notifications from "@kyvg/vue3-notification";
+
+/* FONT AWESOME CONFIG */
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faChevronLeft, faCopy, faDownload, faEdit, faExternalLinkAlt, faLock, faLockOpen, faMoon, faSun, faTrash } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+
+library.add(faCopy, faEdit, faChevronLeft, faMoon, faSun, faLock, faLockOpen, faExternalLinkAlt, faTrash, faDownload)
+
+createApp(App)
+ .use(Notifications)
+ .component('fa-icon', FontAwesomeIcon)
+ .mount("#app"); \ No newline at end of file
diff --git a/extension/src/entries/popup/App.vue b/extension/src/entries/popup/App.vue
new file mode 100644
index 0000000..0181bbb
--- /dev/null
+++ b/extension/src/entries/popup/App.vue
@@ -0,0 +1,22 @@
+<template>
+ <main>
+ <PageContent />
+ </main>
+</template>
+
+<script setup lang="ts">
+import PageContent from "./components/PageContent.vue";
+
+</script>
+
+<style lang="scss">
+main {
+ font-family: Avenir, Helvetica, Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-align: center;
+ color: #2c3e50;
+
+ @apply dark:bg-dark-800 bg-white dark:text-gray-200;
+}
+</style>
diff --git a/extension/src/entries/popup/Components/IdentitySelection.vue b/extension/src/entries/popup/Components/IdentitySelection.vue
new file mode 100644
index 0000000..d95dedb
--- /dev/null
+++ b/extension/src/entries/popup/Components/IdentitySelection.vue
@@ -0,0 +1,46 @@
+<template>
+ <div class="px-3 text-left">
+ <div class="w-full">
+ <div class="">
+ <select class="w-full primary"
+ :disabled="waiting"
+ :value="selected?.Id"
+ @change.prevent="onSelected"
+ >
+ <option disabled value="">Select an identity</option>
+ <option v-for="key in allKeys" :value="key.Id">{{ key.UserName }}</option>
+ </select>
+ </div>
+ </div>
+
+ </div>
+</template>
+
+<script setup lang="ts">
+import { find } from 'lodash'
+import { computed } from "vue";
+import { useStatus, useManagment, NostrPubKey } from "~/bg-api/popup.ts";
+import { useWait } from '@vnuge/vnlib.browser'
+import { computedAsync } from '@vueuse/core';
+
+const { selectedKey } = useStatus();
+const { waiting } = useWait();
+const { getAllKeys, selectKey } = useManagment();
+
+const allKeys = computedAsync<NostrPubKey[]>(async () => await getAllKeys(), []);
+
+const onSelected = async ({target}) =>{
+ //Select the key of the given id
+ const selected = find(allKeys.value, {Id: target.value})
+ if(selected){
+ await selectKey(selected)
+ }
+}
+
+const selected = computed(() => selectedKey?.value || { Id:"0" })
+
+</script>
+
+<style lang="scss">
+
+</style> \ No newline at end of file
diff --git a/extension/src/entries/popup/Components/Login.vue b/extension/src/entries/popup/Components/Login.vue
new file mode 100644
index 0000000..495b64e
--- /dev/null
+++ b/extension/src/entries/popup/Components/Login.vue
@@ -0,0 +1,40 @@
+<template>
+ <div id="login-template" class="py-4">
+ <form class="" @submit.prevent="onSubmit">
+ <fieldset class="px-4 input-container">
+ <label class="">Please enter your authentication token</label>
+ <textarea class="w-full primary" v-model="token" rows="5">
+ </textarea>
+ </fieldset>
+ <div class="flex justify-end mt-2">
+ <div class="px-3">
+ <button class="w-24 rounded btn sm primary">
+ <fa-icon v-if="waiting" icon="spinner" class="animate-spin" />
+ <span v-else>Submit</span>
+ </button>
+ </div>
+ </div>
+ </form>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { useWait } from "@vnuge/vnlib.browser";
+import { ref } from "vue";
+import { useManagment } from "~/bg-api/popup.ts";
+
+const { login } = useManagment()
+const { waiting } = useWait()
+
+const token = ref('')
+
+const onSubmit = async () => {
+ //console.log(token.value)
+ await login(token.value)
+}
+
+</script>
+
+<style lang="scss">
+
+</style> \ No newline at end of file
diff --git a/extension/src/entries/popup/Components/PageContent.vue b/extension/src/entries/popup/Components/PageContent.vue
new file mode 100644
index 0000000..c9b2d5f
--- /dev/null
+++ b/extension/src/entries/popup/Components/PageContent.vue
@@ -0,0 +1,104 @@
+<template>
+ <div
+ id="injected-root"
+ class="flex flex-col text-left w-[20rem] min-h-[25rem]"
+ >
+
+ <div class="flex flex-row w-full px-1 pl-4">
+ <div class="flex-auto my-auto font-mono text-sm">
+ A nostr credential vault
+ </div>
+ <div class="my-auto" v-if="loggedIn">
+ <button class="rounded btn sm red" @click.prevent="logout">
+ <fa-icon icon="arrow-right-from-bracket" />
+ </button>
+ </div>
+ <div class="p-2 my-auto">
+ <button class="rounded btn sm" @click="openOptions">
+ <fa-icon :icon="['fas', 'gear']"/>
+ </button>
+ </div>
+ </div>
+ <div v-if="!loggedIn">
+ <Login></Login>
+ </div>
+ <div v-else class="flex justify-center pb-4">
+ <div class="w-full m-auto">
+ <div class="mt-2 text-center">
+ {{ userName }}
+ <div class="mt-4">
+ <IdentitySelection></IdentitySelection>
+ </div>
+ <div class="mt-2.5 min-h-[6rem]">
+ <div class="flex flex-col justify-center">
+
+ <div class="flex flex-row gap-2 p-2 mx-3 my-3 bg-gray-100 border border-gray-200 rounded dark:bg-dark-700 dark:border-dark-400">
+ <div class="text-sm break-all">
+ {{ pubKey ?? 'No key selected' }}
+ </div>
+ <div class="my-auto ml-auto cursor-pointer" :class="{'text-primary-500': copied }">
+ <fa-icon class="mr-1" icon="copy" @click="copy(pubKey)"/>
+ </div>
+ </div>
+
+ </div>
+ </div>
+ <div class="mt-3 text-sm">
+ Always on NIP-07: <span class="font-semibold" :class="{'text-blue-500':autoInject}">{{ autoInject }}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <notifications class="toaster" group="form" position="top-right" />
+
+ </div>
+</template>
+
+<script setup lang="ts">
+import { computed, watchEffect } from "vue";
+import { useStatus, useManagment } from "~/bg-api/popup.ts";
+import { configureNotifier } from "@vnuge/vnlib.browser";
+import { asyncComputed, useClipboard, watchDebounced } from '@vueuse/core'
+import { notify } from "@kyvg/vue3-notification";
+import { runtime } from "webextension-polyfill";
+import Login from "./Login.vue";
+import IdentitySelection from "./IdentitySelection.vue";
+
+configureNotifier({notify, close:notify.close})
+
+const { loggedIn, userName, selectedKey, darkMode } = useStatus()
+const { logout, getProfile, getSiteConfig } = useManagment()
+
+const { copy, copied } = useClipboard()
+
+const pubKey = computed(() => selectedKey.value?.PublicKey)
+const qrCode = computed(() => pubKey.value ? `nostr:npub1${pubKey.value}` : null)
+
+watchDebounced(loggedIn, async () => {
+ //Manually update the user's profile if they are logged in and the profile is not yet loaded
+ if(loggedIn.value && !userName.value){
+ getProfile()
+ }
+},{ debounce:100, immediate: true })
+
+const openOptions = () => runtime.openOptionsPage();
+
+//Watch for dark mode changes and update the body class
+watchEffect(() => darkMode.value ? document.body.classList.add('dark') : document.body.classList.remove('dark'));
+
+const autoInject = asyncComputed(() => getSiteConfig().then<Boolean>(p => p.autoInject), false)
+
+</script>
+
+<style lang="scss">
+
+.toaster{
+ position: fixed;
+ top: 15px;
+ right: 0;
+ z-index: 9999;
+ max-width: 230px;
+}
+
+</style>
diff --git a/extension/src/entries/popup/index.html b/extension/src/entries/popup/index.html
new file mode 100644
index 0000000..8ffe33b
--- /dev/null
+++ b/extension/src/entries/popup/index.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <title>Popup</title>
+ </head>
+ <body style="min-width: 100px">
+ <div id="app"></div>
+ <script type="module" src="./main.js"></script>
+ </body>
+</html>
diff --git a/extension/src/entries/popup/main.js b/extension/src/entries/popup/main.js
new file mode 100644
index 0000000..d9101ab
--- /dev/null
+++ b/extension/src/entries/popup/main.js
@@ -0,0 +1,32 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import { createApp } from "vue";
+import App from "./App.vue";
+import Notifications from "@kyvg/vue3-notification";
+import '@fontsource/noto-sans-masaram-gondi'
+import "~/assets/tailwind.scss";
+
+/* FONT AWESOME CONFIG */
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faArrowRightFromBracket, faCopy, faEdit, faGear, faSpinner } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+
+library.add(faSpinner, faEdit, faGear, faCopy, faArrowRightFromBracket)
+
+createApp(App)
+ .use(Notifications)
+ .component('fa-icon', FontAwesomeIcon)
+ .mount("#app");
diff --git a/extension/src/manifest.js b/extension/src/manifest.js
new file mode 100644
index 0000000..19d51b1
--- /dev/null
+++ b/extension/src/manifest.js
@@ -0,0 +1,113 @@
+// Copyright (C) 2023 Vaughn Nugent
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+
+import pkg from "../package.json";
+
+const sharedManifest = {
+ content_scripts: [
+ {
+ js: ["src/entries/contentScript/primary/main.js", "src/entries/contentScript/nostr-shim.js"],
+ matches: ["*://*/*"]
+ },
+ ],
+ icons: {
+ 16: "icons/16.png",
+ 32: "icons/32.png",
+ 38: "icons/38.png",
+ 48: "icons/48.png",
+ 72: "icons/72.png",
+ 96: "icons/96.png",
+ },
+ options_ui: {
+ page: "src/entries/options/index.html",
+ open_in_tab: true,
+ browser_style:false
+ },
+ permissions: [
+ 'storage'
+ ],
+
+
+ browser_specific_settings: {
+ "gecko": {
+ "id": "{fdacee2c-bab4-490d-bc4b-ecdd03d5d68a}"
+ }
+ },
+
+ "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self';"
+};
+
+const browserAction = {
+ default_icon: {
+ 16: "icons/16.png",
+ 19: "icons/19.png",
+ 32: "icons/32.png",
+ 38: "icons/38.png",
+ },
+ default_popup: "src/entries/popup/index.html",
+};
+
+const ManifestV2 = {
+ ...sharedManifest,
+ background: {
+ scripts: ["src/entries/background/script.js"],
+ persistent: true,
+ },
+ browser_action: browserAction,
+ options_ui: {
+ ...sharedManifest.options_ui,
+ chrome_style: false,
+ },
+ permissions: [...sharedManifest.permissions, "*://*/*"],
+};
+
+const ManifestV3 = {
+ ...sharedManifest,
+ action: browserAction,
+ background: {
+ service_worker: "src/entries/background/serviceWorker.js",
+ },
+ host_permissions: ["*://*/*"],
+};
+
+export function getManifest(manifestVersion) {
+ const manifest = {
+ author: pkg.author,
+ description: pkg.description,
+ name: pkg.displayName ?? pkg.name,
+ version: pkg.version,
+ };
+
+ if (manifestVersion === 2) {
+ return {
+ ...manifest,
+ ...ManifestV2,
+ manifest_version: manifestVersion,
+ };
+ }
+
+ if (manifestVersion === 3) {
+ return {
+ ...manifest,
+ ...ManifestV3,
+ manifest_version: manifestVersion,
+ };
+ }
+
+ throw new Error(
+ `Missing manifest definition for manifestVersion ${manifestVersion}`
+ );
+}