diff options
author | vnugent <public@vaughnnugent.com> | 2023-11-22 15:07:08 -0500 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2023-11-22 15:07:08 -0500 |
commit | e272adcc3f32e31fe7668551453b8e34bc823c3e (patch) | |
tree | 680c695184ddbc27227578afa9f169d98a69f55a /extension/src | |
parent | 2ba94602a87c87b47f566745bdab40ce75e0e879 (diff) |
feature and internal api polish
Diffstat (limited to 'extension/src')
21 files changed, 383 insertions, 404 deletions
diff --git a/extension/src/entries/contentScript/nostr-shim.js b/extension/src/entries/contentScript/nostr-shim.js deleted file mode 100644 index 418b9c1..0000000 --- a/extension/src/entries/contentScript/nostr-shim.js +++ /dev/null @@ -1,123 +0,0 @@ -// 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 { apiCall } from '@vnuge/vnlib.browser' -import { useScriptTag, watchOnce } from "@vueuse/core" -import { useStore } from '../store' -import { storeToRefs } from 'pinia' - -const _promptHandler = (() => { - let _handler = undefined; - return{ - invoke: (event) => _handler(event), - set: (handler) => _handler = handler - } -})() - -export const usePrompt = (callback) => _promptHandler.set(callback); - - -export const onLoad = async () =>{ - - const store = useStore() - const { nostr } = store.plugins - const { isTabAllowed, selectedKey } = storeToRefs(store) - - const injectHandler = () => { - - //Setup listener for the content script to process nostr messages - const ext = '@vnuge/nvault-extension' - - const scriptUrl = runtime.getURL('src/entries/nostr-provider.js') - - //setup script tag - useScriptTag(scriptUrl, undefined, { manual: false, defer: true }) - - //Only listen for messages if injection is enabled - window.addEventListener('message', async ({ source, data, origin }) => { - - const invokePrompt = async (cb) => { - //await propmt for user to allow the request - const allow = await _promptHandler.invoke({ ...data, origin }) - //send request to background - return response = allow ? await cb() : { error: 'User denied permission' } - } - - //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 - } - - //clean any junk/methods with json parse/stringify - data = JSON.parse(JSON.stringify(data)) - - // pass on to background - var response; - await apiCall(async () => { - switch (data.type) { - case 'getPublicKey': - return invokePrompt(async () => selectedKey.value.PublicKey) - case 'signEvent': - return invokePrompt(async () => { - const event = data.payload.event - - //Set key id to selected key - event.KeyId = selectedKey.value.Id - event.pubkey = selectedKey.value.PublicKey; - - return await nostr.signEvent(event); - }) - //Check the public key against selected key - case 'getRelays': - return invokePrompt(async () => await nostr.getRelays()) - case 'nip04.encrypt': - return invokePrompt(async () => await nostr.nip04Encrypt({ - pubkey: data.payload.peer, - content: data.payload.plaintext, - //Set selected key id as our desired decryption key - KeyId: selectedKey.value.Id - })) - case 'nip04.decrypt': - return invokePrompt(async () => await nostr.nip04Decrypt({ - pubkey: data.payload.peer, - content: data.payload.ciphertext, - //Set selected key id as our desired decryption key - KeyId: selectedKey.value.Id - })) - 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); - }); - } - - //Make sure the origin is allowed - if (store.isTabAllowed === false){ - //If not allowed yet, wait for the store to update - watchOnce(isTabAllowed, val => val ? injectHandler() : undefined); - } - else{ - injectHandler(); - } - -}
\ 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 index b8b7cab..381f7b3 100644 --- a/extension/src/entries/contentScript/primary/components/PromptPopup.vue +++ b/extension/src/entries/contentScript/primary/components/PromptPopup.vue @@ -75,7 +75,7 @@ <script setup lang="ts"> import { ref } from 'vue' -import { usePrompt } from '../../nostr-shim.js' +import { usePrompt, type UserPermissionRequest } from '../../util' import { computed } from 'vue'; import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue' import { clone, first } from 'lodash'; @@ -88,11 +88,7 @@ const keyName = computed(() => selectedKey.value?.UserName) const prompt = ref(null) -interface PopupEvent{ - type: string - msg: string - origin: string - data: any +interface PopupEvent extends UserPermissionRequest { allow: () => void close: () => void } @@ -117,7 +113,7 @@ const allow = () => { } //Listen for events -usePrompt(async (ev: PopupEvent) => { +usePrompt((ev: UserPermissionRequest):Promise<boolean> => { ev = clone(ev) diff --git a/extension/src/entries/contentScript/primary/main.js b/extension/src/entries/contentScript/primary/main.js index e73923d..dbfa07b 100644 --- a/extension/src/entries/contentScript/primary/main.js +++ b/extension/src/entries/contentScript/primary/main.js @@ -13,7 +13,7 @@ // 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 { createApp } from "vue"; import { createPinia } from 'pinia'; import { useBackgroundPiniaPlugin, identityPlugin, originPlugin } from '../../store' @@ -25,7 +25,7 @@ import '@fontsource/noto-sans-masaram-gondi' //We need inline styles to inject into the shadow dom import tw from "~/assets/all.scss?inline"; import localStyle from './style.scss?inline' -import { onLoad } from "../nostr-shim"; +import { onLoad } from "../util"; import { defer } from "lodash"; /* FONT AWESOME CONFIG */ @@ -35,6 +35,10 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' library.add(faCircleInfo) +//The extension name, same as nostr-provider script path +const ext = '@vnuge/nvault-extension' +const scriptUrl = runtime.getURL('src/entries/nostr-provider.js') + renderContent([], (appRoot, shadowRoot) => { //Create the background feature wiring @@ -62,5 +66,5 @@ renderContent([], (appRoot, shadowRoot) => { .mount(appRoot); //Load the nostr shim - defer(onLoad) + defer(() => onLoad(ext, scriptUrl)) });
\ No newline at end of file diff --git a/extension/src/entries/contentScript/util.ts b/extension/src/entries/contentScript/util.ts new file mode 100644 index 0000000..aa6aac3 --- /dev/null +++ b/extension/src/entries/contentScript/util.ts @@ -0,0 +1,138 @@ +// 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 { isEqual, isNil, isEmpty } from 'lodash' +import { apiCall } from '@vnuge/vnlib.browser' +import { Store, storeToRefs } from 'pinia' +import { useScriptTag, watchOnce } from "@vueuse/core" +import { useStore } from '../store' + +export type PromptHandler = (request: UserPermissionRequest) => Promise<boolean> + +export interface UserPermissionRequest { + type: string + msg: string + origin: string + data: any +} + +const _promptHandler = (() => { + let _handler: PromptHandler | undefined = undefined; + return { + invoke(event:UserPermissionRequest){ + if (!_handler) { + throw new Error('No prompt handler set') + } + return _handler(event) + }, + set(handler: PromptHandler) { + _handler = handler + } + } +})() + + +const registerWindowHandler = (store: Store, extName: string) => { + + const { selectedKey } = storeToRefs(store) + const { nostr } = store.plugins; + + //Only listen for messages if injection is enabled + window.addEventListener('message', async ({ source, data, origin }) => { + + //clean any junk/methods with json parse/stringify + data = JSON.parse(JSON.stringify(data)) + + const invokePrompt = async (cb:(...args:any) => Promise<any>) => { + //await propmt for user to allow the request + const allow = await _promptHandler.invoke({ ...data, origin }) + //send request to background + return response = allow ? await cb() : { error: 'User denied permission' } + } + + //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, extName)) { + return + } + + // pass on to background + var response; + await apiCall(async () => { + switch (data.type) { + case 'getPublicKey': + return invokePrompt(async () => selectedKey.value?.PublicKey) + case 'signEvent': + return invokePrompt(async () => { + const event = data.payload.event + + //Set key id to selected key + event.KeyId = selectedKey.value!.Id + event.pubkey = selectedKey.value!.PublicKey; + + return await nostr.signEvent(event); + }) + //Check the public key against selected key + case 'getRelays': + return invokePrompt(async () => await nostr.getRelays()) + case 'nip04.encrypt': + return invokePrompt(async () => await nostr.nip04Encrypt({ + pubkey: data.payload.peer, + content: data.payload.plaintext, + //Set selected key id as our desired decryption key + KeyId: selectedKey.value!.Id + })) + case 'nip04.decrypt': + return invokePrompt(async () => await nostr.nip04Decrypt({ + pubkey: data.payload.peer, + content: data.payload.ciphertext, + //Set selected key id as our desired decryption key + KeyId: selectedKey.value!.Id + })) + default: + throw new Error('Unknown nostr message type') + } + }) + // return response message, must have the same id as the request + window.postMessage({ ext: extName, id: data.id, response }, origin); + }); +} + +export const usePrompt = (callback: PromptHandler) => _promptHandler.set(callback); + +export const onLoad = async (extName: string, scriptUrl: string) => { + + const store = useStore() + const { isTabAllowed } = storeToRefs(store) + + const injectHandler = () => { + //inject the nostr provider script into the page + useScriptTag(scriptUrl, undefined, { manual: false, defer: true }) + //Regsiter listener for messages from the injected script + registerWindowHandler(store, extName) + } + + //Make sure the origin is allowed + if (isTabAllowed.value === false) { + //If not allowed yet, wait for the store to update + watchOnce(isTabAllowed, val => val ? injectHandler() : undefined); + } + else { + injectHandler(); + } +}
\ No newline at end of file diff --git a/extension/src/entries/nostr-provider.js b/extension/src/entries/nostr-provider.js index 9fa3bb7..7d8f3c5 100644 --- a/extension/src/entries/nostr-provider.js +++ b/extension/src/entries/nostr-provider.js @@ -74,9 +74,9 @@ window.nostr = { } , async signEvent(event){ - const { event:ev } = await sendMessage('signEvent', { event }) - debugLog("Signed event", ev); - return ev + const signed = await sendMessage('signEvent', { event }) + debugLog("Signed event", signed); + return signed }, getRelays(){ diff --git a/extension/src/entries/options/components/Identities.vue b/extension/src/entries/options/components/Identities.vue index e3af74e..0e3e79d 100644 --- a/extension/src/entries/options/components/Identities.vue +++ b/extension/src/entries/options/components/Identities.vue @@ -158,11 +158,10 @@ const onNip05Download = () => { //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(); - + const anchor = get(downloadAnchor); + anchor!.href = URL.createObjectURL(blob); + anchor!.setAttribute('download', 'nip05.json') + anchor!.click(); }) } diff --git a/extension/src/entries/options/components/SiteSettings.vue b/extension/src/entries/options/components/SiteSettings.vue index c0c2e93..b31bb9c 100644 --- a/extension/src/entries/options/components/SiteSettings.vue +++ b/extension/src/entries/options/components/SiteSettings.vue @@ -7,7 +7,7 @@ Extension settings </h3> <div class="my-6"> - <fieldset :disabled="waiting.value"> + <fieldset :disabled="waiting"> <div class=""> <div class="w-full"> <div class="flex flex-row w-full"> @@ -139,7 +139,7 @@ const { apply, data, buffer, modified, update } = useDataBuffer(settings.value, }) //Watch for store settings changes and apply them -watch(settings, v => apply(v.value)) +watch(settings, v => apply(v)) const originProtection = computed({ get: () => isOriginProtectionOn.value, @@ -196,7 +196,6 @@ const onSave = async () => { toggleEdit(); } - const testConnection = async () =>{ return await apiCall(async ({axios, toaster}) =>{ try{ diff --git a/extension/src/entries/store/allowedOrigins.ts b/extension/src/entries/store/allowedOrigins.ts index d6de42d..661dd43 100644 --- a/extension/src/entries/store/allowedOrigins.ts +++ b/extension/src/entries/store/allowedOrigins.ts @@ -3,15 +3,15 @@ import 'pinia' import { } from 'lodash' import { PiniaPluginContext } from 'pinia' import { computed, shallowRef } from 'vue'; -import { onWatchableChange } from '../../features/types'; +import { onWatchableChange } from '../../features'; import { type AllowedOriginStatus } from '../../features/nip07allow-api'; declare module 'pinia' { export interface PiniaCustomProperties { - isTabAllowed: boolean; - currentOrigin: string | undefined; - allowedOrigins: Array<string>; - isOriginProtectionOn: boolean; + readonly isTabAllowed: boolean; + readonly currentOrigin: string | undefined; + readonly allowedOrigins: Array<string>; + readonly isOriginProtectionOn: boolean; allowOrigin(origin?:string): Promise<void>; dissallowOrigin(origin?:string): Promise<void>; disableOriginProtection(): Promise<void>; diff --git a/extension/src/entries/store/features.ts b/extension/src/entries/store/features.ts index 219386f..c619b0e 100644 --- a/extension/src/entries/store/features.ts +++ b/extension/src/entries/store/features.ts @@ -13,10 +13,10 @@ import { useSettingsApi, useForegoundFeatures, useEventTagFilterApi, - useInjectAllowList + useInjectAllowList, + onWatchableChange } from "../../features" -import { onWatchableChange } from '../../features/types' import { ChannelContext } from '../../messaging' export type BgPlugins = ReturnType<typeof usePlugins> diff --git a/extension/src/entries/store/identity.ts b/extension/src/entries/store/identity.ts index 320263f..b1635f2 100644 --- a/extension/src/entries/store/identity.ts +++ b/extension/src/entries/store/identity.ts @@ -2,9 +2,8 @@ import 'pinia' import { } from 'lodash' import { PiniaPluginContext } from 'pinia' -import { NostrPubKey } from '../../features' +import { onWatchableChange, type NostrPubKey } from '../../features' import { shallowRef } from 'vue'; -import { onWatchableChange } from '../../features/types'; declare module 'pinia' { export interface PiniaCustomStateProperties { @@ -17,7 +16,6 @@ declare module 'pinia' { } } - export const identityPlugin = ({ store }: PiniaPluginContext) => { const { identity } = store.plugins diff --git a/extension/src/features/account-api.ts b/extension/src/features/account-api.ts index 9c701c3..96948c4 100644 --- a/extension/src/features/account-api.ts +++ b/extension/src/features/account-api.ts @@ -13,16 +13,15 @@ // 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 { useMfaConfig, usePkiConfig, PkiPublicKey, debugLog } from "@vnuge/vnlib.browser"; +import { useMfaConfig, usePkiConfig, type PkiPublicKey } from "@vnuge/vnlib.browser"; import { ArrayToHexString, Base64ToUint8Array } from "@vnuge/vnlib.browser/dist/binhelpers"; import { JsonObject } from "type-fest"; -import { useSingleSlotStorage } from "./types"; import { computed, watch } from "vue"; -import { storage } from "webextension-polyfill"; import { JWK, SignJWT, importJWK } from "jose"; -import { cloneDeep } from "lodash"; +import { clone } from "lodash"; import { FeatureApi, BgRuntime, IFeatureExport, exportForegroundApi, optionsOnly, popupAndOptionsOnly } from "./framework"; import { AppSettings } from "./settings"; +import { set, toRefs } from "@vueuse/core"; export interface EcKeyParams extends JsonObject { @@ -83,7 +82,7 @@ export const usePkiApi = (): IFeatureExport<AppSettings, PkiApi> => { interface PkiSettings { userName: string, - privateKey?:JWK + privateKey:JWK | undefined } export interface LocalPkiApi extends FeatureApi { @@ -97,17 +96,17 @@ export const useLocalPki = (): IFeatureExport<AppSettings, LocalPkiApi> => { return{ //Setup registration background: ({ state } : BgRuntime<AppSettings>) =>{ - const { get, set } = useSingleSlotStorage<PkiSettings>(storage.local, 'pki-settings') + const store = state.useStorageSlot<PkiSettings>('pki-settings', { userName: '', privateKey: undefined }) + const { userName, privateKey } = toRefs(store) const getPubKey = async (): Promise<PkiPubKey | undefined> => { - const setting = await get() - if (!setting?.privateKey) { + if (!privateKey.value) { return undefined } //Clone the private key, remove the private parts - const c = cloneDeep(setting.privateKey) + const c = clone(privateKey.value) delete c.d delete c.p @@ -118,12 +117,12 @@ export const useLocalPki = (): IFeatureExport<AppSettings, LocalPkiApi> => { return { ...c, - userName: setting.userName + userName: userName.value } as PkiPubKey } return{ - regenerateKey: optionsOnly(async (userName:string, params:EcKeyParams) => { + regenerateKey: optionsOnly(async (uname:string, params:EcKeyParams) => { const p = { ...params, name: "ECDSA", @@ -133,46 +132,47 @@ export const useLocalPki = (): IFeatureExport<AppSettings, LocalPkiApi> => { const key = await window.crypto.subtle.generateKey(p, true, ['sign', 'verify']) //Convert to jwk - const privateKey = await window.crypto.subtle.exportKey('jwk', key.privateKey) as JWK; + const newKey = await window.crypto.subtle.exportKey('jwk', key.privateKey) as JWK; //Convert to base64 so we can hash it easier - const b = btoa(privateKey.x! + privateKey.y!); + const b = btoa(newKey.x! + newKey.y!); //take sha256 of the binary version of the coords const digest = await crypto.subtle.digest('SHA-256', Base64ToUint8Array(b)); //Set the kid - privateKey.kid = ArrayToHexString(digest); + newKey.kid = ArrayToHexString(digest); //Serial number is random hex const serial = new Uint8Array(32) crypto.getRandomValues(serial) - privateKey.serial = ArrayToHexString(serial); + newKey.serial = ArrayToHexString(serial); - //Save the key - await set({ userName, privateKey }) + //Set the username + set(userName, uname) + set(privateKey, newKey) }), getPubKey: optionsOnly(getPubKey), generateOtp: optionsOnly(async () =>{ - const setting = await get() - if (!setting?.privateKey) { + + if (!privateKey.value) { throw new Error('No key found') } - const privKey = await importJWK(setting.privateKey as JWK) + const privKey = await importJWK(privateKey.value as JWK) const random = new Uint8Array(32) crypto.getRandomValues(random) const jwt = new SignJWT({ - 'sub': setting.userName, + 'sub': userName.value, 'n': ArrayToHexString(random), - keyid: setting.privateKey.kid, - serial: privKey.serial + keyid: privateKey.value.kid, + serial: (privKey as any).serial }); const token = await jwt.setIssuedAt() - .setProtectedHeader({ alg: setting.privateKey.alg! }) + .setProtectedHeader({ alg: privateKey.value.alg! }) .setIssuer(state.currentConfig.value.apiUrl) .setExpirationTime('30s') .sign(privKey) diff --git a/extension/src/features/auth-api.ts b/extension/src/features/auth-api.ts index 81bf6ea..e3f6c21 100644 --- a/extension/src/features/auth-api.ts +++ b/extension/src/features/auth-api.ts @@ -14,13 +14,14 @@ // along with this program. If not, see <https://www.gnu.org/licenses/>. import { AxiosInstance } from "axios"; -import { get, watchOnce } from "@vueuse/core"; +import { get } from "@vueuse/core"; import { computed } from "vue"; import { usePkiAuth, useSession, useUser } from "@vnuge/vnlib.browser"; -import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, popupAndOptionsOnly, popupOnly } from "./framework"; import type { ClientStatus } from "./types"; import type { AppSettings } from "./settings"; import type { JsonObject } from "type-fest"; +import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, popupAndOptionsOnly, popupOnly } from "./framework"; +import { waitForChangeFn } from "./util"; export interface ProectedHandler<T extends JsonObject> { (message: T): Promise<any> @@ -77,14 +78,13 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => { onInstalled(() => { //Configure interval to run every 5 minutes to update the status setInterval(runHeartbeat, 60 * 1000); - //Run immediately runHeartbeat(); - return Promise.resolve(); }) return { + waitForChange: waitForChangeFn([currentConfig, loggedIn, userName]), login: popupOnly(async (token: string): Promise<boolean> => { //Perform login await login(token) @@ -107,9 +107,6 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => { userName: get(userName), } as ClientStatus }, - async waitForChange(){ - return new Promise((resolve) => watchOnce([currentConfig, loggedIn] as any, () => resolve())) - } } }, foreground: exportForegroundApi<UserApi>([ diff --git a/extension/src/features/identity-api.ts b/extension/src/features/identity-api.ts index d7db5ff..a8ac4e6 100644 --- a/extension/src/features/identity-api.ts +++ b/extension/src/features/identity-api.ts @@ -13,7 +13,7 @@ // 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 { Endpoints, useServerApi } from "./server-api"; +import { Endpoints } from "./server-api"; import { NostrPubKey, Watchable } from "./types"; import { type FeatureApi, @@ -26,8 +26,9 @@ import { import { AppSettings } from "./settings"; import { shallowRef, watch } from "vue"; import { useSession } from "@vnuge/vnlib.browser"; -import { set, useToggle, watchOnce } from "@vueuse/core"; +import { set, useToggle } from "@vueuse/core"; import { defer, isArray } from "lodash"; +import { waitForChange, waitForChangeFn } from "./util"; export interface IdentityApi extends FeatureApi, Watchable { createIdentity: (identity: NostrPubKey) => Promise<NostrPubKey> @@ -41,13 +42,13 @@ export interface IdentityApi extends FeatureApi, Watchable { export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => { return{ background: ({ state }: BgRuntime<AppSettings>) =>{ - const { execRequest } = useServerApi(state); + const { execRequest } = state.useServerApi(); const { loggedIn } = useSession(); //Get the current selected key const selectedKey = shallowRef<NostrPubKey | undefined>(); const allKeys = shallowRef<NostrPubKey[]>([]); - const [ onTriggered , triggerChange ] = useToggle() + const [ onKeyUpdateTriggered , triggerKeyUpdate ] = useToggle() const keyLoadWatchLoop = async () => { while(true){ @@ -62,7 +63,7 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => { } //Wait for changes to trigger a new key-load - await new Promise((resolve) => watchOnce([onTriggered, loggedIn] as any, () => resolve(null))) + await waitForChange([loggedIn, onKeyUpdateTriggered]) } } @@ -74,16 +75,18 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => { return { //Identity is only available in options context createIdentity: optionsOnly(async (id: NostrPubKey) => { - await execRequest(Endpoints.CreateId, id) - triggerChange() + const newKey = await execRequest(Endpoints.CreateId, id) + triggerKeyUpdate() + return newKey }), updateIdentity: optionsOnly(async (id: NostrPubKey) => { - await execRequest(Endpoints.UpdateId, id) - triggerChange() + const updated = await execRequest(Endpoints.UpdateId, id) + triggerKeyUpdate() + return updated }), deleteIdentity: optionsOnly(async (key: NostrPubKey) => { await execRequest(Endpoints.DeleteKey, key); - triggerChange() + triggerKeyUpdate() }), selectKey: popupAndOptionsOnly((key: NostrPubKey): Promise<void> => { set(selectedKey, key); @@ -95,10 +98,8 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => { getPublicKey: (): Promise<NostrPubKey | undefined> => { return Promise.resolve(selectedKey.value); }, - waitForChange: () => { - return new Promise((resolve) => watchOnce([selectedKey, loggedIn, onTriggered] as any, () => resolve())) - } - } + waitForChange: waitForChangeFn([selectedKey, loggedIn, allKeys]) + } }, foreground: exportForegroundApi([ 'createIdentity', diff --git a/extension/src/features/index.ts b/extension/src/features/index.ts index 320ea1c..c146cef 100644 --- a/extension/src/features/index.ts +++ b/extension/src/features/index.ts @@ -30,4 +30,5 @@ export { useNostrApi } from './nostr-api' export { useSettingsApi, useAppSettings } from './settings' export { useHistoryApi } from './history' export { useEventTagFilterApi } from './tagfilter-api' -export { useInjectAllowList } from './nip07allow-api'
\ No newline at end of file +export { useInjectAllowList } from './nip07allow-api' +export { onWatchableChange } from './util'
\ No newline at end of file diff --git a/extension/src/features/nip07allow-api.ts b/extension/src/features/nip07allow-api.ts index 0612b66..8c08d5e 100644 --- a/extension/src/features/nip07allow-api.ts +++ b/extension/src/features/nip07allow-api.ts @@ -13,13 +13,14 @@ // 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 { storage, tabs, type Tabs } from "webextension-polyfill"; -import { Watchable, useSingleSlotStorage } from "./types"; +import { tabs, type Tabs } from "webextension-polyfill"; +import { Watchable } from "./types"; import { defaultTo, filter, includes, isEqual } from "lodash"; import { BgRuntime, FeatureApi, IFeatureExport, exportForegroundApi, popupAndOptionsOnly } from "./framework"; import { AppSettings } from "./settings"; -import { set, get, watchOnce, useToggle } from "@vueuse/core"; +import { set, get, toRefs } from "@vueuse/core"; import { computed, shallowRef } from "vue"; +import { waitForChangeFn } from "./util"; interface AllowedSites{ origins: string[]; @@ -41,14 +42,10 @@ export interface InjectAllowlistApi extends FeatureApi, Watchable { export const useInjectAllowList = (): IFeatureExport<AppSettings, InjectAllowlistApi> => { return { - background: ({ }: BgRuntime<AppSettings>) => { + background: ({ state }: BgRuntime<AppSettings>) => { - const store = useSingleSlotStorage<AllowedSites>(storage.local, 'nip07-allowlist', { origins: [], enabled: true }); - - //watch current tab - const allowedOrigins = shallowRef<string[]>([]) - const protectionEnabled = shallowRef<boolean>(true) - const [manullyTriggered, trigger] = useToggle() + const store = state.useStorageSlot<AllowedSites>('nip07-allowlist', { origins: [], enabled: true }); + const { origins, enabled } = toRefs(store) const { currentOrigin, currentTab } = (() => { @@ -72,19 +69,9 @@ export const useInjectAllowList = (): IFeatureExport<AppSettings, InjectAllowlis return { currentTab, currentOrigin } })() - const writeChanges = async () => { - await store.set({ origins: get(allowedOrigins), enabled: get(protectionEnabled) }) - } - - //Initial load - store.get().then((data) => { - allowedOrigins.value = data.origins - protectionEnabled.value = data.enabled - }) - const isOriginAllowed = (origin?: string): boolean => { //If protection is not enabled, allow all - if(protectionEnabled.value == false){ + if(enabled.value == false){ return true; } //if no origin specified, use current origin @@ -97,7 +84,7 @@ export const useInjectAllowList = (): IFeatureExport<AppSettings, InjectAllowlis //Default to origin only const originOnly = new URL(origin).origin - return includes(allowedOrigins.value, originOnly) + return includes(origins.value, originOnly) } const addOrigin = async (origin?: string): Promise<void> => { @@ -110,13 +97,9 @@ export const useInjectAllowList = (): IFeatureExport<AppSettings, InjectAllowlis const originOnly = new URL(newOrigin).origin //See if origin is already in the list - if (!includes(allowedOrigins.value, originOnly)) { + if (!includes(origins.value, originOnly)) { //Add to the list - allowedOrigins.value.push(originOnly); - trigger(); - - //Save changes - await writeChanges() + origins.value.push(originOnly); //If current tab was added, reload the tab if (!origin) { @@ -134,40 +117,32 @@ export const useInjectAllowList = (): IFeatureExport<AppSettings, InjectAllowlis //Get origin part of url const delOriginOnly = new URL(delOrigin).origin - const allowList = get(allowedOrigins) + const allowList = get(origins) //Remove the origin - allowedOrigins.value = filter(allowList, (o) => !isEqual(o, delOriginOnly)); - trigger(); - - await writeChanges() + origins.value = filter(allowList, (o) => !isEqual(o, delOriginOnly)); //If current tab was removed, reload the tab if (!origin) { await tabs.reload(currentTab.value?.id) } } - return { + waitForChange: waitForChangeFn([currentTab, enabled, origins]), addOrigin: popupAndOptionsOnly(addOrigin), removeOrigin: popupAndOptionsOnly(removeOrigin), enable: popupAndOptionsOnly(async (value: boolean): Promise<void> => { - set(protectionEnabled, value) - await writeChanges() + set(enabled, value) }), async getStatus(): Promise<AllowedOriginStatus> { return{ - allowedOrigins: get(allowedOrigins), - enabled: get(protectionEnabled), + allowedOrigins: get(origins), + enabled: get(enabled), currentOrigin: get(currentOrigin), isAllowed: isOriginAllowed() } }, - async waitForChange() { - //Wait for the trigger to change - await new Promise((resolve) => watchOnce([currentTab, protectionEnabled, manullyTriggered] as any, () => resolve(null))); - }, } }, foreground: exportForegroundApi([ diff --git a/extension/src/features/nostr-api.ts b/extension/src/features/nostr-api.ts index 743f8f1..81ef0c6 100644 --- a/extension/src/features/nostr-api.ts +++ b/extension/src/features/nostr-api.ts @@ -13,7 +13,7 @@ // 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 { Endpoints, useServerApi } from "./server-api"; +import { Endpoints } from "./server-api"; import { type FeatureApi, type BgRuntime, type IFeatureExport, optionsOnly, exportForegroundApi } from "./framework"; import { type AppSettings } from "./settings"; import { useTagFilter } from "./tagfilter-api"; @@ -38,8 +38,8 @@ export const useNostrApi = (): IFeatureExport<AppSettings, NostrApi> => { return{ background: ({ state }: BgRuntime<AppSettings>) =>{ - const { execRequest } = useServerApi(state); - const { filterTags } = useTagFilter() + const { execRequest } = state.useServerApi(); + const { filterTags } = useTagFilter(state) return { getRelays: async (): Promise<NostrRelay[]> => { diff --git a/extension/src/features/server-api/index.ts b/extension/src/features/server-api/index.ts index 6637aaa..fd8e65e 100644 --- a/extension/src/features/server-api/index.ts +++ b/extension/src/features/server-api/index.ts @@ -14,12 +14,11 @@ // along with this program. If not, see <https://www.gnu.org/licenses/>. -import { computed } from "vue" +import { Ref } from "vue" import { get } from '@vueuse/core' import { type WebMessage, type UserProfile } from "@vnuge/vnlib.browser" import { initEndponts } from "./endpoints" import { cloneDeep } from "lodash" -import { type AppSettings } from "../settings" import type { EncryptionRequest, NostrEvent, NostrPubKey, NostrRelay } from "../types" export enum Endpoints { @@ -48,12 +47,12 @@ export interface ExecRequestHandler{ (id: Endpoints.UpdateProfile, profile: UserProfile):Promise<string> } -export const useServerApi = (settings: AppSettings): { execRequest: ExecRequestHandler } => { - const { registerEndpoint, execRequest } = initEndponts() +export interface ServerApi{ + execRequest: ExecRequestHandler +} - //ref to nostr endpoint url - const nostrUrl = computed(() => settings.currentConfig.value.nostrEndpoint || '/nostr'); - const accUrl = computed(() => settings.currentConfig.value.accountBasePath || '/account'); +export const useServerApi = (nostrUrl: Ref<string>, accUrl: Ref<string>): ServerApi => { + const { registerEndpoint, execRequest } = initEndponts() registerEndpoint({ id: Endpoints.GetKeys, diff --git a/extension/src/features/settings.ts b/extension/src/features/settings.ts index 059c2d2..9a3c32d 100644 --- a/extension/src/features/settings.ts +++ b/extension/src/features/settings.ts @@ -14,13 +14,15 @@ // along with this program. If not, see <https://www.gnu.org/licenses/>. import { storage } from "webextension-polyfill" -import { isEmpty, merge } from 'lodash' +import { } from 'lodash' import { configureApi, debugLog } from '@vnuge/vnlib.browser' -import { readonly, ref, Ref } from "vue"; +import { MaybeRefOrGetter, readonly, Ref, shallowRef, watch } from "vue"; import { JsonObject } from "type-fest"; -import { Watchable, useSingleSlotStorage } from "./types"; +import { Watchable } from "./types"; import { BgRuntime, FeatureApi, optionsOnly, IFeatureExport, exportForegroundApi, popupAndOptionsOnly } from './framework' -import { get, watchOnce } from "@vueuse/core"; +import { get, set, toRefs } from "@vueuse/core"; +import { waitForChangeFn, useStorage } from "./util"; +import { ServerApi, useServerApi } from "./server-api"; export interface PluginConfig extends JsonObject { readonly apiUrl: string; @@ -42,9 +44,9 @@ const defaultConfig : PluginConfig = { }; export interface AppSettings{ - getCurrentConfig: () => Promise<PluginConfig>; - restoreApiSettings: () => Promise<void>; - saveConfig: (config: PluginConfig) => Promise<void>; + saveConfig(config: PluginConfig): void; + useStorageSlot<T>(slot: string, defaultValue: MaybeRefOrGetter<T>): Ref<T>; + useServerApi(): ServerApi, readonly currentConfig: Readonly<Ref<PluginConfig>>; } @@ -56,27 +58,11 @@ export interface SettingsApi extends FeatureApi, Watchable { } export const useAppSettings = (): AppSettings => { - const currentConfig = ref<PluginConfig>({} as PluginConfig); - const store = useSingleSlotStorage<PluginConfig>(storage.local, 'siteConfig', defaultConfig); - const getCurrentConfig = async () => { - - const siteConfig = await store.get() - - //Store a default config if none exists - if (isEmpty(siteConfig)) { - await store.set(defaultConfig); - } - - //Merge the default config with the site config - return merge(defaultConfig, siteConfig) - } - - const restoreApiSettings = async () => { - //Set the current config - const current = await getCurrentConfig(); - currentConfig.value = current; + const _storageBackend = storage.local; + const store = useStorage<PluginConfig>(_storageBackend, 'siteConfig', defaultConfig); + watch(store, (config, _) => { //Configure the vnlib api configureApi({ session: { @@ -84,74 +70,58 @@ export const useAppSettings = (): AppSettings => { browserIdSize: 32, }, user: { - accountBasePath: current.accountBasePath, + accountBasePath: config.accountBasePath, }, axios: { - baseURL: current.apiUrl, + baseURL: config.apiUrl, tokenHeader: import.meta.env.VITE_WEB_TOKEN_HEADER, }, storage: localStorage }) - } - const saveConfig = async (config: PluginConfig) => { - //Save the config and update the current config - await store.set(config); - currentConfig.value = config; - } + }, { deep: true }) + + //Save the config and update the current config + const saveConfig = (config: PluginConfig) => set(store, config); + + //Reactive urls for server api + const { accountBasePath, nostrEndpoint } = toRefs(store) + const serverApi = useServerApi(nostrEndpoint, accountBasePath) return { - getCurrentConfig, - restoreApiSettings, saveConfig, - currentConfig:readonly(currentConfig) + currentConfig: readonly(store), + useStorageSlot: <T>(slot: string, defaultValue: MaybeRefOrGetter<T>) => { + return useStorage<T>(_storageBackend, slot, defaultValue) + }, + useServerApi: () => serverApi } } export const useSettingsApi = () : IFeatureExport<AppSettings, SettingsApi> =>{ return{ - background: ({ state, onConnected, onInstalled }: BgRuntime<AppSettings>) => { - - const _darkMode = ref(false); + background: ({ state }: BgRuntime<AppSettings>) => { - onInstalled(async () => { - await state.restoreApiSettings(); - debugLog('Server settings restored from storage'); - }) - - onConnected(async () => { - //refresh the config on connect - await state.restoreApiSettings(); - }) + const _darkMode = shallowRef(false); return { - - getSiteConfig: () => state.getCurrentConfig(), - + waitForChange: waitForChangeFn([state.currentConfig, _darkMode]), + getSiteConfig: () => Promise.resolve(state.currentConfig.value), setSiteConfig: optionsOnly(async (config: PluginConfig): Promise<PluginConfig> => { //Save the config - await state.saveConfig(config); - - //Restore the api settings - await state.restoreApiSettings(); + state.saveConfig(config); debugLog('Config settings saved!'); //Return the config - return state.currentConfig.value + return get(state.currentConfig) }), - setDarkMode: popupAndOptionsOnly(async (darkMode: boolean) => { - console.log('Setting dark mode to', darkMode, 'from', _darkMode.value) _darkMode.value = darkMode }), getDarkMode: async () => get(_darkMode), - - waitForChange: () => { - return new Promise((resolve) => watchOnce([state.currentConfig, _darkMode] as any, () => resolve())) - }, } }, foreground: exportForegroundApi([ diff --git a/extension/src/features/tagfilter-api.ts b/extension/src/features/tagfilter-api.ts index 75f4b2a..f5f1b6c 100644 --- a/extension/src/features/tagfilter-api.ts +++ b/extension/src/features/tagfilter-api.ts @@ -13,37 +13,40 @@ // 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 { storage } from "webextension-polyfill"; -import { NostrEvent, TaggedNostrEvent, useSingleSlotStorage } from "./types"; +import { TaggedNostrEvent, Watchable } from "./types"; import { filter, isEmpty, isEqual, isRegExp } from "lodash"; import { BgRuntime, FeatureApi, IFeatureExport, exportForegroundApi } from "./framework"; import { AppSettings } from "./settings"; +import { get, toRefs } from "@vueuse/core"; +import { waitForChangeFn } from "./util"; interface EventTagFilteStorage { filters: string[]; + enabled: boolean; } -export interface EventTagFilterApi extends FeatureApi { +export interface EventTagFilterApi extends FeatureApi, Watchable { filterTags(event: TaggedNostrEvent): Promise<void>; addFilter(tag: string): Promise<void>; removeFilter(tag: string): Promise<void>; addFilters(tags: string[]): Promise<void>; + isEnabled(): Promise<boolean>; + enable(value:boolean): Promise<void>; } -export const useTagFilter = () => { +export const useTagFilter = (settings: AppSettings): EventTagFilterApi => { //use storage - const { get, set } = useSingleSlotStorage<EventTagFilteStorage>(storage.local, 'tag-filter-struct', { filters: [] }); + const store = settings.useStorageSlot<EventTagFilteStorage>('tag-filter-struct', { filters: [], enabled: false }); + const { filters, enabled } = toRefs(store) return { + waitForChange: waitForChangeFn([filters, enabled]), filterTags: async (event: TaggedNostrEvent): Promise<void> => { if(!event.tags){ return; } - //Load latest filter list - const data = await get(); - if(isEmpty(event.tags)){ return; } @@ -58,13 +61,13 @@ export const useTagFilter = () => { return false; } - if(!data.filters.length){ + if(!filters.value.length){ return true; } const asString = tagName.toString(); - for (const filter of data.filters) { + for (const filter of get(filters)) { //if the filter is a regex, test it, if it fails, its allowed if (isRegExp(filter)) { if (filter.test(asString)) { @@ -85,35 +88,32 @@ export const useTagFilter = () => { event.tags = allowedTags; }, addFilter: async (tag: string) => { - const data = await get(); //add new filter to list - data.filters.push(tag); - //save new config - await set(data); + filters.value.push(tag); }, removeFilter: async (tag: string) => { - const data = await get(); //remove filter from list - data.filters = filter(data.filters, (t) => !isEqual(t, tag)); - //save new config - await set(data); + filters.value = filter(filters.value, t => !isEqual(t, tag)); }, addFilters: async (tags: string[]) => { - const data = await get(); //add new filters to list - data.filters.push(...tags); - //save new config - await set(data); + filters.value.push(...tags); + }, + isEnabled: async () => { + return enabled.value; + }, + enable: async (value:boolean) => { + enabled.value = value; } } } export const useEventTagFilterApi = (): IFeatureExport<AppSettings, EventTagFilterApi> => { return{ - background: ({ }: BgRuntime<AppSettings>) => { + background: ({ state }: BgRuntime<AppSettings>) => { return{ - ...useTagFilter() - } + ...useTagFilter(state) + } }, foreground: exportForegroundApi([ 'filterTags', diff --git a/extension/src/features/types.ts b/extension/src/features/types.ts index a64be93..856b95a 100644 --- a/extension/src/features/types.ts +++ b/extension/src/features/types.ts @@ -13,7 +13,6 @@ // 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 { defer } from "lodash"; import { JsonObject } from "type-fest"; export interface NostrPubKey extends JsonObject { @@ -90,68 +89,11 @@ export interface LoginMessage extends JsonObject { } export interface Watchable{ + /** + * Waits for a change to the state of the + * watchable object. This is a one-shot promise + * that will resolve when the state changes. This + * must be called again to listen for the next change. + */ waitForChange(): Promise<void>; -} - -export const useStorage = (storage: any & chrome.storage.StorageArea) => { - const get = async <T>(key: string): Promise<T | undefined> => { - const value = await storage.get(key) - return value[key] as T; - } - - const set = async <T>(key: string, value: T): Promise<void> => { - await storage.set({ [key]: value }); - } - - const remove = async (key: string): Promise<void> => { - await storage.remove(key); - } - - return { get, set, remove } -} - -export interface SingleSlotStorage<T>{ - get(): Promise<T | undefined>; - set(value: T): Promise<void>; - remove(): Promise<void>; -} - -export interface DefaultSingleSlotStorage<T>{ - get(): Promise<T>; - set(value: T): Promise<void>; - remove(): Promise<void>; -} - -export interface UseSingleSlotStorage{ - <T>(storage: any & chrome.storage.StorageArea, key: string): SingleSlotStorage<T>; - <T>(storage: any & chrome.storage.StorageArea, key: string, defaultValue: T): DefaultSingleSlotStorage<T>; -} - -const _useSingleSlotStorage = <T>(storage: any & chrome.storage.StorageArea, key: string, defaultValue?: T) => { - const s = useStorage(storage); - - const get = async (): Promise<T | undefined> => { - return await s.get<T>(key) || defaultValue; - } - - const set = (value: T): Promise<void> => s.set(key, value); - const remove = (): Promise<void> => s.remove(key); - - return { get, set, remove } -} - -export const useSingleSlotStorage: UseSingleSlotStorage = _useSingleSlotStorage; - -export const onWatchableChange = (watchable: Watchable, onChangeCallback: () => Promise<any>, controls? : { immediate: boolean}) => { - - defer(async () => { - if (controls?.immediate) { - await onChangeCallback(); - } - - while (true) { - await watchable.waitForChange(); - await onChangeCallback(); - } - }) }
\ No newline at end of file diff --git a/extension/src/features/util.ts b/extension/src/features/util.ts new file mode 100644 index 0000000..e9147bc --- /dev/null +++ b/extension/src/features/util.ts @@ -0,0 +1,83 @@ +// 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 { defer } from "lodash"; +import { RemovableRef, SerializerAsync, StorageLikeAsync, useStorageAsync, watchOnce } from "@vueuse/core"; +import { type MaybeRefOrGetter, type WatchSource, isProxy, toRaw } from "vue"; +import type { Watchable } from "./types"; + +export const waitForChange = <T extends Readonly<WatchSource<unknown>[]>>(source: [...T]):Promise<void> => { + return new Promise((resolve) => watchOnce(source, () => resolve())) +} + +export const waitForChangeFn = <T extends Readonly<WatchSource<unknown>[]>>(source: [...T]) => { + return (): Promise<void> => { + return new Promise((resolve) => watchOnce(source, () => resolve())) + } +} + +export const useStorage = <T>(storage: any & chrome.storage.StorageArea, key: string, initialValue: MaybeRefOrGetter<T>): RemovableRef<T> => { + + const wrapper: StorageLikeAsync = { + + async getItem(key: string): Promise<any | undefined> { + const value = await storage.get(key) + //pass the raw value to the serializer + return value[key] as T; + }, + async setItem(key: string, value: any): Promise<void> { + //pass the raw value to storage + await storage.set({ [key]: value }); + }, + async removeItem(key: string): Promise<void> { + await storage.remove(key); + } + } + + /** + * Custom sealizer that passes the raw + * values to the storage, the storage + * wrapper above will store the raw values + * as is. + */ + const serializer: SerializerAsync<T> = { + async read(value: any) { + return value as T + }, + async write(value: any) { + if (isProxy(value)) { + return toRaw(value) + } + return value; + } + } + + return useStorageAsync<T>(key, initialValue, wrapper, { serializer, deep: true, shallow: true }); +} + +export const onWatchableChange = (watchable: Watchable, onChangeCallback: () => Promise<any>, controls?: { immediate: boolean }) => { + + defer(async () => { + if (controls?.immediate) { + await onChangeCallback(); + } + + while (true) { + await watchable.waitForChange(); + await onChangeCallback(); + } + }) +}
\ No newline at end of file |