diff options
Diffstat (limited to 'extension')
39 files changed, 557 insertions, 1559 deletions
diff --git a/extension/package-lock.json b/extension/package-lock.json index 9af8ef3..f55baef 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -17,7 +17,7 @@ "@fortawesome/vue-fontawesome": "^3.0.3", "@headlessui/vue": "^1.7.x", "@kyvg/vue3-notification": "^3.0.x", - "@vnuge/vnlib.browser": "../../vnlib.browser/vnlib.browser", + "@vnuge/vnlib.browser": "https://www.vaughnnugent.com/public/resources/software/builds/vnlib.browser/11288db78d5e83421ac2ffe5168bbf7eecb085bc/@vnuge-vnlib.browser/release.tgz", "@vuelidate/core": "^2.0.0", "@vuelidate/validators": "^2.0.0", "@vueuse/core": "^10.3.2", @@ -25,7 +25,6 @@ "base32-encode": "^2.0.0", "jose": "^5.0.x", "lodash-es": "^4.17.21", - "nanoevents": "^8.0.0", "pinia": "^2.1.7", "sass": "^1.56.1", "serialize-error": "^11.0.0", @@ -56,27 +55,6 @@ "web-ext": "^7.4.0" } }, - "../../vnlib.browser/vnlib.browser": { - "name": "@vnuge/vnlib.browser", - "version": "0.1.12", - "license": "MIT", - "devDependencies": { - "@babel/types": "^7.x", - "@types/lodash-es": "^4.14.x", - "@types/node": "^20.5.1", - "@typescript-eslint/eslint-plugin": "^6.x.x" - }, - "peerDependencies": { - "@vueuse/core": "^10.x", - "axios": "^1.x", - "eslint": "^8.39.0", - "jose": "^5.x", - "lodash-es": "^4.x", - "universal-cookie": "^6.1.x", - "vue": "^3.x", - "vue-router": "^4.x" - } - }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -1163,8 +1141,20 @@ } }, "node_modules/@vnuge/vnlib.browser": { - "resolved": "../../vnlib.browser/vnlib.browser", - "link": true + "version": "0.1.12", + "resolved": "https://www.vaughnnugent.com/public/resources/software/builds/vnlib.browser/11288db78d5e83421ac2ffe5168bbf7eecb085bc/@vnuge-vnlib.browser/release.tgz", + "integrity": "sha512-7rYhcPg5mcFb1NWwlYpqjJ9ROHPIibg7n1eUVrd2fDCpgB9DU7rOXlD49ABJMHb1GE5XNGDzA9olWw90aXWRNg==", + "license": "MIT", + "peerDependencies": { + "@vueuse/core": "^10.x", + "axios": "^1.x", + "eslint": "^8.39.0", + "jose": "^5.x", + "lodash-es": "^4.x", + "universal-cookie": "^6.1.x", + "vue": "^3.x", + "vue-router": "^4.x" + } }, "node_modules/@volar-plugins/vetur": { "version": "2.0.0", @@ -2239,7 +2229,6 @@ "version": "1.6.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", - "devOptional": true, "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -3914,7 +3903,6 @@ "version": "1.15.3", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", - "devOptional": true, "funding": [ { "type": "individual", @@ -3968,7 +3956,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "devOptional": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -5437,14 +5424,6 @@ "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==", "optional": true }, - "node_modules/nanoevents": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/nanoevents/-/nanoevents-8.0.0.tgz", - "integrity": "sha512-bYYwNCdNc5ea6/Lwh1uioU1/7aaKa3EPmNQ2weTm8PWSpbWrsaWHePe0Zq4SF+D3F3JX3cn+QdktOPCf1meOqw==", - "engines": { - "node": "^16.0.0 || ^18.0.0 || >=20.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -6233,8 +6212,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "devOptional": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/psl": { "version": "1.9.0", @@ -7990,7 +7968,6 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.5.tgz", "integrity": "sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==", - "dev": true, "dependencies": { "@vue/devtools-api": "^6.5.0" }, diff --git a/extension/package.json b/extension/package.json index bff2a81..129617a 100644 --- a/extension/package.json +++ b/extension/package.json @@ -44,7 +44,7 @@ "@fortawesome/vue-fontawesome": "^3.0.3", "@headlessui/vue": "^1.7.x", "@kyvg/vue3-notification": "^3.0.x", - "@vnuge/vnlib.browser": "../../vnlib.browser/vnlib.browser", + "@vnuge/vnlib.browser": "https://www.vaughnnugent.com/public/resources/software/builds/vnlib.browser/11288db78d5e83421ac2ffe5168bbf7eecb085bc/@vnuge-vnlib.browser/release.tgz", "@vuelidate/core": "^2.0.0", "@vuelidate/validators": "^2.0.0", "@vueuse/core": "^10.3.2", @@ -52,7 +52,6 @@ "base32-encode": "^2.0.0", "jose": "^5.0.x", "lodash-es": "^4.17.21", - "nanoevents": "^8.0.0", "pinia": "^2.1.7", "sass": "^1.56.1", "serialize-error": "^11.0.0", diff --git a/extension/src/assets/inputs.scss b/extension/src/assets/inputs.scss index 05c8d33..480285e 100644 --- a/extension/src/assets/inputs.scss +++ b/extension/src/assets/inputs.scss @@ -45,8 +45,9 @@ label.checkbox { /*Select */ select.input { + @apply appearance-none; option { - @apply dark:bg-dark-700 ; + @apply dark:bg-dark-700 p-0 m-0; } } diff --git a/extension/src/entries/contentScript/nostr-shim.js b/extension/src/entries/contentScript/nostr-shim.js index eddc678..418b9c1 100644 --- a/extension/src/entries/contentScript/nostr-shim.js +++ b/extension/src/entries/contentScript/nostr-shim.js @@ -17,7 +17,6 @@ import { runtime } from "webextension-polyfill" import { isEqual, isNil, isEmpty } from 'lodash' import { apiCall } from '@vnuge/vnlib.browser' import { useScriptTag, watchOnce } from "@vueuse/core" -import { createPort } from '../../webext-bridge/' import { useStore } from '../store' import { storeToRefs } from 'pinia' @@ -34,11 +33,14 @@ 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 { sendMessage } = createPort('content-script') const scriptUrl = runtime.getURL('src/entries/nostr-provider.js') @@ -47,6 +49,14 @@ export const onLoad = async () =>{ //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 @@ -56,21 +66,42 @@ export const onLoad = async () =>{ 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': - //await propmt for user to allow the request - const allow = await _promptHandler.invoke({ ...data, origin }) - //send request to background - response = allow ? await sendMessage(data.type, { ...data.payload, origin }, 'background') : { error: 'User denied permission' } - break; + 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') } @@ -80,14 +111,10 @@ export const onLoad = async () =>{ }); } - const store = useStore() - const { isTabAllowed } = storeToRefs(store) - //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); - return; } else{ injectHandler(); diff --git a/extension/src/entries/contentScript/primary/components/PromptPopup.vue b/extension/src/entries/contentScript/primary/components/PromptPopup.vue index 195c6db..b8b7cab 100644 --- a/extension/src/entries/contentScript/primary/components/PromptPopup.vue +++ b/extension/src/entries/contentScript/primary/components/PromptPopup.vue @@ -1,80 +1,89 @@ <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="[keyName ? '' : 'text-red-500']"> - {{ keyName ?? 'Select Identity' }} + <div v-show="isOpen" id="nvault-ext-prompt" :class="{'dark': darkMode }"> + + <div class="absolute top-0 bottom-0 left-0 right-0 text-white" style="z-index:9147483647 !important" > + <div class="fixed inset-0 left-0 w-full h-full bg-black/50" @click.self="close" /> + <div class="relative w-full max-w-[28rem] mx-auto mt-36 mb-auto" ref="prompt"> + <div class="w-full p-5 bg-white border rounded-lg shadow-lg dark:bg-dark-900 dark:border-dark-500"> + <div v-if="loggedIn" class="text-gray-800 dark:text-gray-200"> + + <div class="flex flex-row justify-between"> + <div class=""> + <div class="text-lg font-bold"> + Allow access + </div> + <div class="text-sm"> + <span class=""> + Identity: + </span> + <span :class="[keyName ? '' : 'text-red-500']"> + {{ keyName ?? 'Select Identity' }} + </span> </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> + <Popover class="relative"> + <PopoverButton class=""> + <fa-icon icon="circle-info" class="w-4 h-4" /> + </PopoverButton> + <PopoverPanel class="absolute right-0 z-10"> + <div class="min-w-[22rem] p-2 border rounded dark:bg-dark-800 bg-gray-50 dark:border-dark-500 shadow-md text-sm"> + <p class="pl-1"> + Event Data: + </p> + <div class="p-2 mt-1 text-left border rounded dark:border-dark-500 border-gray-300 overflow-auto max-h-[22rem] max-w-lg"> + <pre>{{ evData }}</pre> </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> + </PopoverPanel> + </Popover> </div> </div> - <div v-else class=""> - <h3 class="">Log in!</h3> - <div class=""> - You must log in before you can allow access. + + <div class="py-3 text-sm text-center"> + <span class="font-bold">{{ site }}</span> + would like to access to + <span class="font-bold">{{ event?.msg }}</span> + </div> + + <div class="flex gap-2 mt-4"> + <div class="ml-auto"> + <button class="rounded btn sm" @click="close">Close</button> </div> - <div class="flex justify-end gap-2 mt-4"> - <div> - <button class="rounded btn sm red" @click="close">Close</button> - </div> + <div> + <button :disabled="selectedKey?.Id == undefined" class="rounded btn sm" @click="allow">Allow</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 xs" @click="close">Close</button> </div> </div> </div> </div> </div> </div> + </div> </template> <script setup lang="ts"> import { ref } from 'vue' import { usePrompt } from '../../nostr-shim.js' -import { computed } from '@vue/reactivity'; -import { } from '@vueuse/core'; +import { computed } from 'vue'; import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue' -import { first } from 'lodash'; +import { clone, first } from 'lodash'; import { useStore } from '../../../store'; import { storeToRefs } from 'pinia'; const store = useStore() -const { loggedIn, selectedKey } = storeToRefs(store) +const { loggedIn, selectedKey, darkMode } = storeToRefs(store) const keyName = computed(() => selectedKey.value?.UserName) const prompt = ref(null) @@ -107,12 +116,11 @@ const allow = () => { res?.allow() } -//Setup click outside -//onClickOutside(prompt, () => isOpen.value ? close() : null) - //Listen for events usePrompt(async (ev: PopupEvent) => { + ev = clone(ev) + console.log('[usePrompt] =>', ev) switch(ev.type){ @@ -131,6 +139,9 @@ usePrompt(async (ev: PopupEvent) => { case 'nip04.decrypt': ev.msg = "decrypt data" break; + default: + ev.msg = "unknown event" + break; } return new Promise((resolve) => { @@ -142,10 +153,4 @@ usePrompt(async (ev: PopupEvent) => { }) }) - </script> - -<style lang="scss"> - - -</style> diff --git a/extension/src/entries/contentScript/primary/main.js b/extension/src/entries/contentScript/primary/main.js index bbf0932..e73923d 100644 --- a/extension/src/entries/contentScript/primary/main.js +++ b/extension/src/entries/contentScript/primary/main.js @@ -28,6 +28,13 @@ import localStyle from './style.scss?inline' import { onLoad } from "../nostr-shim"; import { defer } from "lodash"; +/* FONT AWESOME CONFIG */ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faCircleInfo } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' + +library.add(faCircleInfo) + renderContent([], (appRoot, shadowRoot) => { //Create the background feature wiring @@ -38,14 +45,6 @@ renderContent([], (appRoot, shadowRoot) => { .use(identityPlugin) .use(originPlugin) - createApp(App) - .use(store) - .use(Notification) - .mount(appRoot); - - //Load the nostr shim - defer(onLoad) - //Add tailwind styles just to the shadow dom element const style = document.createElement('style') style.innerHTML = tw.toString() @@ -55,4 +54,13 @@ renderContent([], (appRoot, shadowRoot) => { const style2 = document.createElement('style') style2.innerHTML = localStyle.toString() shadowRoot.appendChild(style2) + + createApp(App) + .use(store) + .use(Notification) + .component('fa-icon', FontAwesomeIcon) + .mount(appRoot); + + //Load the nostr shim + defer(onLoad) });
\ No newline at end of file diff --git a/extension/src/entries/contentScript/renderContent.js b/extension/src/entries/contentScript/renderContent.js index 84c5b9f..293bdd5 100644 --- a/extension/src/entries/contentScript/renderContent.js +++ b/extension/src/entries/contentScript/renderContent.js @@ -21,9 +21,7 @@ export default async function renderContent( render = (_appRoot) => {} ) { const appContainer = document.createElement("div"); - const shadowRoot = appContainer.attachShadow({ - mode: import.meta.env.DEV ? "open" : "closed", - }); + const shadowRoot = appContainer.attachShadow({ mode: 'closed' }); const appRoot = document.createElement("div"); if (import.meta.hot) { diff --git a/extension/src/entries/nostr-provider.js b/extension/src/entries/nostr-provider.js index 1b8807f..9fa3bb7 100644 --- a/extension/src/entries/nostr-provider.js +++ b/extension/src/entries/nostr-provider.js @@ -69,9 +69,8 @@ window.addEventListener('message', ({ data }) => { window.nostr = { //Redirect calls to the background script - async getPublicKey(){ - const { PublicKey } = await sendMessage('getPublicKey', {}) - return PublicKey + getPublicKey(){ + return sendMessage('getPublicKey', {}) } , async signEvent(event){ @@ -80,9 +79,8 @@ window.nostr = { return ev }, - async getRelays(){ - const { relays } = await sendMessage('getRelays', {}) - return relays + getRelays(){ + return sendMessage('getRelays', {}) }, nip04: { diff --git a/extension/src/entries/popup/Components/Login.vue b/extension/src/entries/popup/Components/Login.vue index 44df714..93c0178 100644 --- a/extension/src/entries/popup/Components/Login.vue +++ b/extension/src/entries/popup/Components/Login.vue @@ -31,7 +31,7 @@ const token = ref('') const onSubmit = async () => { await apiCall(async ({ toaster }) => { await login(token.value) - toaster.general.success({ + toaster.form.success({ 'title': 'Login successful', 'text': 'Successfully logged into your profile' }) diff --git a/extension/src/entries/popup/Components/PageContent.vue b/extension/src/entries/popup/Components/PageContent.vue index 1a3995e..e4fcb49 100644 --- a/extension/src/entries/popup/Components/PageContent.vue +++ b/extension/src/entries/popup/Components/PageContent.vue @@ -19,6 +19,12 @@ <fa-icon icon="arrow-right-from-bracket" /> </button> </div> + <div class="my-auto"> + <button class="rounded btn xs" @click="toggleDark" > + <fa-icon class="w-4" v-if="darkMode" icon="sun"/> + <fa-icon class="w-4" v-else icon="moon" /> + </button> + </div> <div class="my-auto"> <button class="rounded btn xs" @click="openOptions"> <fa-icon :icon="['fas', 'gear']"/> @@ -106,6 +112,7 @@ const { copy, copied } = useClipboard() const pubKey = computed(() => selectedKey!.value?.PublicKey) const openOptions = () => runtime.openOptionsPage(); +const toggleDark = () => store.toggleDarkMode() //Watch for dark mode changes and update the body class watchEffect(() => darkMode.value ? document.body.classList.add('dark') : document.body.classList.remove('dark')); diff --git a/extension/src/entries/popup/main.js b/extension/src/entries/popup/main.js index a259e63..8b8a3d9 100644 --- a/extension/src/entries/popup/main.js +++ b/extension/src/entries/popup/main.js @@ -24,10 +24,10 @@ import "./local.scss" /* FONT AWESOME CONFIG */ import { library } from '@fortawesome/fontawesome-svg-core' -import { faArrowRightFromBracket, faCopy, faEdit, faGear, faMinus, faPlus, faSpinner } from '@fortawesome/free-solid-svg-icons' +import { faArrowRightFromBracket, faCopy, faEdit, faGear, faMinus, faMoon, faPlus, faSpinner, faSun } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' -library.add(faSpinner, faEdit, faGear, faCopy, faArrowRightFromBracket, faPlus, faMinus) +library.add(faSpinner, faEdit, faGear, faCopy, faArrowRightFromBracket, faPlus, faMinus, faSun, faMoon) const bgPlugin = useBackgroundPiniaPlugin('popup') diff --git a/extension/src/entries/store/allowedOrigins.ts b/extension/src/entries/store/allowedOrigins.ts index 7fc5e15..d6de42d 100644 --- a/extension/src/entries/store/allowedOrigins.ts +++ b/extension/src/entries/store/allowedOrigins.ts @@ -2,7 +2,7 @@ import 'pinia' import { } from 'lodash' import { PiniaPluginContext } from 'pinia' -import { computed, ref } from 'vue'; +import { computed, shallowRef } from 'vue'; import { onWatchableChange } from '../../features/types'; import { type AllowedOriginStatus } from '../../features/nip07allow-api'; @@ -22,7 +22,7 @@ declare module 'pinia' { export const originPlugin = ({ store }: PiniaPluginContext) => { const { plugins } = store - const status = ref<AllowedOriginStatus>() + const status = shallowRef<AllowedOriginStatus>() onWatchableChange(plugins.allowedOrigins, async () => { //Update the status diff --git a/extension/src/entries/store/features.ts b/extension/src/entries/store/features.ts index 9bf3052..219386f 100644 --- a/extension/src/entries/store/features.ts +++ b/extension/src/entries/store/features.ts @@ -2,10 +2,8 @@ import 'pinia' import { } from 'lodash' import { PiniaPluginContext } from 'pinia' -import { type Tabs, tabs } from 'webextension-polyfill' import { - SendMessageHandler, useAuthApi, useHistoryApi, useIdentityApi, @@ -18,9 +16,8 @@ import { useInjectAllowList } from "../../features" -import { RuntimeContext, createPort } from '../../webext-bridge' -import { ref } from 'vue' import { onWatchableChange } from '../../features/types' +import { ChannelContext } from '../../messaging' export type BgPlugins = ReturnType<typeof usePlugins> export type BgPluginState<T> = { plugins: BgPlugins } & T @@ -28,13 +25,12 @@ export type BgPluginState<T> = { plugins: BgPlugins } & T declare module 'pinia' { export interface PiniaCustomProperties { plugins: BgPlugins - currentTab: Tabs.Tab | undefined } } -const usePlugins = (sendMessage: SendMessageHandler) => { +const usePlugins = (context: ChannelContext) => { //Create plugin wrapping function - const { use } = useForegoundFeatures(sendMessage) + const { use } = useForegoundFeatures(context) return { settings: use(useSettingsApi), @@ -49,14 +45,11 @@ const usePlugins = (sendMessage: SendMessageHandler) => { } } -export const useBackgroundPiniaPlugin = (context: RuntimeContext) => { +export const useBackgroundPiniaPlugin = (context: ChannelContext) => { //Create port for context - const { sendMessage } = createPort(context) - const plugins = usePlugins(sendMessage) + const plugins = usePlugins(context) const { user } = plugins; - const currentTab = ref<Tabs.Tab | undefined>(undefined) - //Plugin store return ({ store }: PiniaPluginContext) => { @@ -71,46 +64,13 @@ export const useBackgroundPiniaPlugin = (context: RuntimeContext) => { //Wait for settings changes onWatchableChange(plugins.settings, async () => { - //Update settings and dark mode on change store.settings = await plugins.settings.getSiteConfig(); store.darkMode = await plugins.settings.getDarkMode(); - console.log("Settings changed") }, { immediate: true }) - - - const initTab = async () => { - - if(!tabs){ - return; - } - - //Get the current tab - const [active] = await tabs.query({ active: true, currentWindow: true }) - currentTab.value = active - - //Watch for changes to the current tab - tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { - //If the url changed, update the current tab - if (changeInfo.url) { - currentTab.value = tab - } - }) - - tabs.onActivated.addListener(async ({ tabId }) => { - //Get the tab - const tab = await tabs.get(tabId) - //Update the current tab - currentTab.value = tab - }) - } - - - initTab() return{ plugins, - currentTab, } } }
\ No newline at end of file diff --git a/extension/src/entries/store/identity.ts b/extension/src/entries/store/identity.ts index 58a6b67..320263f 100644 --- a/extension/src/entries/store/identity.ts +++ b/extension/src/entries/store/identity.ts @@ -3,7 +3,7 @@ import 'pinia' import { } from 'lodash' import { PiniaPluginContext } from 'pinia' import { NostrPubKey } from '../../features' -import { ref } from 'vue'; +import { shallowRef } from 'vue'; import { onWatchableChange } from '../../features/types'; declare module 'pinia' { @@ -22,17 +22,22 @@ export const identityPlugin = ({ store }: PiniaPluginContext) => { const { identity } = store.plugins - const allKeys = ref<NostrPubKey[]>([]) - const selectedKey = ref<NostrPubKey | undefined>(undefined) + const originalReset = store.$reset.bind(store) + const allKeys = shallowRef<NostrPubKey[]>([]) + const selectedKey = shallowRef<NostrPubKey | undefined>(undefined) onWatchableChange(identity, async () => { + console.log('Identity changed') allKeys.value = await identity.getAllKeys(); - //Get the current key selectedKey.value = await identity.getPublicKey(); - console.log('Selected key is now', selectedKey.value) }, { immediate:true }) return { + $reset(){ + originalReset() + allKeys.value = [] + selectedKey.value = undefined + }, selectedKey, allKeys, selectKey: identity.selectKey, diff --git a/extension/src/features/framework/index.ts b/extension/src/features/framework/index.ts index c58ca68..44ae031 100644 --- a/extension/src/features/framework/index.ts +++ b/extension/src/features/framework/index.ts @@ -15,12 +15,11 @@ // along with this program. If not, see <https://www.gnu.org/licenses/>. import { runtime } from "webextension-polyfill"; -import { createBackgroundPort } from '../../webext-bridge' -import { BridgeMessage, RuntimeContext, isInternalEndpoint } from "../../webext-bridge"; import { serializeError, deserializeError } from 'serialize-error'; import { JsonObject } from "type-fest"; -import { cloneDeep, isObjectLike, set } from "lodash"; +import { cloneDeep, isArray, isObjectLike, set } from "lodash"; import { debugLog } from "@vnuge/vnlib.browser"; +import { ChannelContext, createMessageChannel } from "../../messaging"; export interface BgRuntime<T> { readonly state: T; @@ -68,16 +67,15 @@ export interface IBackgroundWrapper<TState> { } export interface ProtectedFunction extends Function { - readonly protection: RuntimeContext[] + readonly protection: ChannelContext[] } export const optionsOnly = <T extends Function>(func: T): T => protectMethod(func, 'options'); export const popupOnly = <T extends Function>(func: T): T => protectMethod(func, 'popup'); export const contentScriptOnly = <T extends Function>(func: T): T => protectMethod(func, 'content-script'); -export const windowOnly = <T extends Function>(func: T): T => protectMethod(func, 'window'); export const popupAndOptionsOnly = <T extends Function>(func: T): T => protectMethod(func, 'popup', 'options'); -export const protectMethod = <T extends Function>(func: T, ...protection: RuntimeContext[]): T => { +export const protectMethod = <T extends Function>(func: T, ...protection: ChannelContext[]): T => { (func as any).protection = protection return func; } @@ -88,14 +86,16 @@ export const protectMethod = <T extends Function>(func: T, ...protection: Runtim */ export const useBackgroundFeatures = <TState>(state: TState): IBackgroundWrapper<TState> => { + const { openOnMessageChannel } = createMessageChannel('background'); + const { onMessage } = openOnMessageChannel() + + const rt = { state, onConnected: runtime.onConnect.addListener, onInstalled: runtime.onInstalled.addListener, } as BgRuntime<TState> - const { onMessage } = createBackgroundPort() - /** * Each plugin will export named methods. Background methods * are captured and registered as on-message handlers that @@ -120,20 +120,25 @@ export const useBackgroundFeatures = <TState>(state: TState): IBackgroundWrapper const onMessageFuncName = `${feature.name}-${externFuncName}` //register method with api - onMessage(onMessageFuncName, async (msg: BridgeMessage<any>) => { + onMessage<any>(onMessageFuncName, async (sender, payload) => { try { - //Always an internal endpoint - if (!isInternalEndpoint(msg.sender)) { + if ((func as ProtectedFunction).protection + && !(func as ProtectedFunction).protection.includes(sender)) { throw new Error(`Unauthorized external call to ${onMessageFuncName}`) } - if ((func as ProtectedFunction).protection - && !(func as ProtectedFunction).protection.includes(msg.sender.context)) { - throw new Error(`Unauthorized external call to ${onMessageFuncName}`) + const res = await func(...payload) + + if(isArray(res)){ + return [...res] + } + else if(isObjectLike(res)){ + return { ...res } + } + else{ + return res } - const res = await func(...msg.data) - return isObjectLike(res) ? { ...res} : res } catch (e: any) { debugLog(`Error in method ${onMessageFuncName}`, e) @@ -154,8 +159,11 @@ export const useBackgroundFeatures = <TState>(state: TState): IBackgroundWrapper * Creates a foreground runtime context for unwrapping foreground stub * methods and redirecting them to thier background handler */ -export const useForegoundFeatures = (sendMessage: SendMessageHandler): IForegroundUnwrapper => { +export const useForegoundFeatures = (context: ChannelContext): IForegroundUnwrapper => { + const { openChannel } = createMessageChannel(context); + const { sendMessage } = openChannel() + /** * The goal of this function is to get the foreground interface object * that should match the background implementation. All methods are @@ -211,4 +219,4 @@ export const exportForegroundApi = <T extends FeatureApi>(args: DummyApiExport<T } return () => type -}
\ No newline at end of file +} diff --git a/extension/src/features/identity-api.ts b/extension/src/features/identity-api.ts index 07b6387..d7db5ff 100644 --- a/extension/src/features/identity-api.ts +++ b/extension/src/features/identity-api.ts @@ -24,10 +24,10 @@ import { exportForegroundApi } from "./framework"; import { AppSettings } from "./settings"; -import { ref, watch } from "vue"; +import { shallowRef, watch } from "vue"; import { useSession } from "@vnuge/vnlib.browser"; -import { get, set, useToggle, watchOnce } from "@vueuse/core"; -import { find, isArray } from "lodash"; +import { set, useToggle, watchOnce } from "@vueuse/core"; +import { defer, isArray } from "lodash"; export interface IdentityApi extends FeatureApi, Watchable { createIdentity: (identity: NostrPubKey) => Promise<NostrPubKey> @@ -45,56 +45,57 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => { const { loggedIn } = useSession(); //Get the current selected key - const selectedKey = ref<NostrPubKey | undefined>(); + const selectedKey = shallowRef<NostrPubKey | undefined>(); + const allKeys = shallowRef<NostrPubKey[]>([]); const [ onTriggered , triggerChange ] = useToggle() + const keyLoadWatchLoop = async () => { + while(true){ + //Load keys from server if logged in + if(loggedIn.value){ + const [...keys] = await execRequest(Endpoints.GetKeys); + allKeys.value = isArray(keys) ? keys : []; + } + else{ + //Clear all keys when logged out + allKeys.value = []; + } + + //Wait for changes to trigger a new key-load + await new Promise((resolve) => watchOnce([onTriggered, loggedIn] as any, () => resolve(null))) + } + } + + defer(keyLoadWatchLoop) + //Clear the selected key if the user logs out watch(loggedIn, (li) => li ? null : selectedKey.value = undefined) return { //Identity is only available in options context createIdentity: optionsOnly(async (id: NostrPubKey) => { - await execRequest<NostrPubKey>(Endpoints.CreateId, id) + await execRequest(Endpoints.CreateId, id) triggerChange() }), updateIdentity: optionsOnly(async (id: NostrPubKey) => { - await execRequest<NostrPubKey>(Endpoints.UpdateId, id) + await execRequest(Endpoints.UpdateId, id) triggerChange() }), deleteIdentity: optionsOnly(async (key: NostrPubKey) => { - await execRequest<NostrPubKey>(Endpoints.DeleteKey, key); + await execRequest(Endpoints.DeleteKey, key); triggerChange() }), selectKey: popupAndOptionsOnly((key: NostrPubKey): Promise<void> => { - selectedKey.value = key; + set(selectedKey, key); return Promise.resolve() }), - getAllKeys: async (): Promise<NostrPubKey[]> => { - if(!get(loggedIn)){ - return [] - } - //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)) { - set(selectedKey, undefined); - } - } - - return [...data] + getAllKeys: (): Promise<NostrPubKey[]> => { + return Promise.resolve(allKeys.value); }, getPublicKey: (): Promise<NostrPubKey | undefined> => { return Promise.resolve(selectedKey.value); }, waitForChange: () => { - console.log('Waiting for change') return new Promise((resolve) => watchOnce([selectedKey, loggedIn, onTriggered] as any, () => resolve())) } } diff --git a/extension/src/features/nip07allow-api.ts b/extension/src/features/nip07allow-api.ts index eff4ff8..0612b66 100644 --- a/extension/src/features/nip07allow-api.ts +++ b/extension/src/features/nip07allow-api.ts @@ -19,7 +19,7 @@ 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 { computed, ref } from "vue"; +import { computed, shallowRef } from "vue"; interface AllowedSites{ origins: string[]; @@ -46,13 +46,13 @@ export const useInjectAllowList = (): IFeatureExport<AppSettings, InjectAllowlis const store = useSingleSlotStorage<AllowedSites>(storage.local, 'nip07-allowlist', { origins: [], enabled: true }); //watch current tab - const allowedOrigins = ref<string[]>([]) - const protectionEnabled = ref<boolean>(true) + const allowedOrigins = shallowRef<string[]>([]) + const protectionEnabled = shallowRef<boolean>(true) const [manullyTriggered, trigger] = useToggle() const { currentOrigin, currentTab } = (() => { - const currentTab = ref<Tabs.Tab | undefined>(undefined) + const currentTab = shallowRef<Tabs.Tab | undefined>(undefined) const currentOrigin = computed(() => currentTab.value?.url ? new URL(currentTab.value.url).origin : undefined) //Watch for changes to the current tab @@ -73,7 +73,7 @@ export const useInjectAllowList = (): IFeatureExport<AppSettings, InjectAllowlis })() const writeChanges = async () => { - await store.set({ origins: [...get(allowedOrigins)], enabled: get(protectionEnabled) }) + await store.set({ origins: get(allowedOrigins), enabled: get(protectionEnabled) }) } //Initial load @@ -158,15 +158,15 @@ export const useInjectAllowList = (): IFeatureExport<AppSettings, InjectAllowlis }), async getStatus(): Promise<AllowedOriginStatus> { return{ - allowedOrigins: [...get(allowedOrigins)], + allowedOrigins: get(allowedOrigins), enabled: get(protectionEnabled), currentOrigin: get(currentOrigin), isAllowed: isOriginAllowed() } }, - waitForChange: () => { + async waitForChange() { //Wait for the trigger to change - return new Promise((resolve) => watchOnce([currentTab, protectionEnabled, manullyTriggered] as any, () => resolve())); + await new Promise((resolve) => watchOnce([currentTab, protectionEnabled, manullyTriggered] as any, () => resolve(null))); }, } }, diff --git a/extension/src/features/nostr-api.ts b/extension/src/features/nostr-api.ts index 307522d..743f8f1 100644 --- a/extension/src/features/nostr-api.ts +++ b/extension/src/features/nostr-api.ts @@ -14,10 +14,11 @@ // along with this program. If not, see <https://www.gnu.org/licenses/>. import { Endpoints, useServerApi } from "./server-api"; -import { NostrRelay, EventMessage, NostrEvent } from './types' -import { FeatureApi, BgRuntime, IFeatureExport, optionsOnly, exportForegroundApi } from "./framework"; -import { AppSettings } from "./settings"; +import { type FeatureApi, type BgRuntime, type IFeatureExport, optionsOnly, exportForegroundApi } from "./framework"; +import { type AppSettings } from "./settings"; import { useTagFilter } from "./tagfilter-api"; +import type { NostrRelay, EncryptionRequest, NostrEvent } from './types'; +import { cloneDeep } from "lodash"; /** @@ -28,8 +29,8 @@ export interface NostrApi extends FeatureApi { getRelays: () => Promise<NostrRelay[]>; signEvent: (event: NostrEvent) => Promise<NostrEvent | undefined>; setRelay: (relay: NostrRelay) => Promise<NostrRelay | undefined>; - nip04Encrypt: (data: EventMessage) => Promise<string>; - nip04Decrypt: (data: EventMessage) => Promise<string>; + nip04Encrypt: (data: EncryptionRequest) => Promise<string>; + nip04Decrypt: (data: EncryptionRequest) => Promise<string>; } export const useNostrApi = (): IFeatureExport<AppSettings, NostrApi> => { @@ -43,28 +44,41 @@ export const useNostrApi = (): IFeatureExport<AppSettings, NostrApi> => { return { getRelays: async (): Promise<NostrRelay[]> => { //Get preferred relays for the current user - const data = await execRequest<NostrRelay[]>(Endpoints.GetRelays) - return [...data] + const [...relays] = await execRequest(Endpoints.GetRelays) + return relays; }, signEvent: async (req: NostrEvent): Promise<NostrEvent | undefined> => { + //Store copy to prevent mutation + req = cloneDeep(req) + //If tag filter is enabled, filter before continuing if(state.currentConfig.value.tagFilter){ await filterTags(req) } //Sign the event - const event = await execRequest<NostrEvent>(Endpoints.SignEvent, req); + const event = await execRequest(Endpoints.SignEvent, req); return event; }, - nip04Encrypt: async (data: EventMessage): Promise<string> => { - return execRequest<string>(Endpoints.Encrypt, data); + nip04Encrypt: async (data: EncryptionRequest): Promise<string> => { + const message: EncryptionRequest = { + content: data.content, + KeyId: data.KeyId, + pubkey: data.pubkey + } + return execRequest(Endpoints.Encrypt, message); }, - nip04Decrypt: (data: EventMessage): Promise<string> => { - return execRequest<string>(Endpoints.Decrypt, data); + nip04Decrypt: (data: EncryptionRequest): Promise<string> => { + const message: EncryptionRequest = { + content: data.content, + KeyId: data.KeyId, + pubkey: data.pubkey + } + return execRequest(Endpoints.Decrypt, message); }, setRelay: optionsOnly((relay: NostrRelay): Promise<NostrRelay | undefined> => { - return execRequest<NostrRelay>(Endpoints.SetRelay, relay) + return execRequest(Endpoints.SetRelay, relay) }), } }, diff --git a/extension/src/features/server-api/index.ts b/extension/src/features/server-api/index.ts index 6aa34da..6637aaa 100644 --- a/extension/src/features/server-api/index.ts +++ b/extension/src/features/server-api/index.ts @@ -18,10 +18,9 @@ import { computed } from "vue" import { get } from '@vueuse/core' import { type WebMessage, type UserProfile } from "@vnuge/vnlib.browser" import { initEndponts } from "./endpoints" -import { type NostrIdentiy } from "../foreground/types" import { cloneDeep } from "lodash" import { type AppSettings } from "../settings" -import type { NostrEvent, NostrRelay } from "../types" +import type { EncryptionRequest, NostrEvent, NostrPubKey, NostrRelay } from "../types" export enum Endpoints { GetKeys = 'getKeys', @@ -36,7 +35,20 @@ export enum Endpoints { UpdateProfile = 'updateProfile', } -export const useServerApi = (settings: AppSettings) => { +export interface ExecRequestHandler{ + (id: Endpoints.GetKeys):Promise<NostrPubKey[]> + (id: Endpoints.DeleteKey, key: NostrPubKey):Promise<void> + (id: Endpoints.SignEvent, event: NostrEvent):Promise<NostrEvent> + (id: Endpoints.GetRelays):Promise<NostrRelay[]> + (id: Endpoints.SetRelay, relay: NostrRelay):Promise<NostrRelay> + (id: Endpoints.Encrypt, data: EncryptionRequest):Promise<string> + (id: Endpoints.Decrypt, data: EncryptionRequest):Promise<string> + (id: Endpoints.CreateId, identity: NostrPubKey):Promise<NostrPubKey> + (id: Endpoints.UpdateId, identity: NostrPubKey):Promise<NostrPubKey> + (id: Endpoints.UpdateProfile, profile: UserProfile):Promise<string> +} + +export const useServerApi = (settings: AppSettings): { execRequest: ExecRequestHandler } => { const { registerEndpoint, execRequest } = initEndponts() //ref to nostr endpoint url @@ -54,7 +66,7 @@ export const useServerApi = (settings: AppSettings) => { registerEndpoint({ id: Endpoints.DeleteKey, method: 'DELETE', - path: (key:NostrIdentiy) => `${get(nostrUrl)}?type=identity&key_id=${key.Id}`, + path: (key: NostrPubKey) => `${get(nostrUrl)}?type=identity&key_id=${key.Id}`, onRequest: () => Promise.resolve(), onResponse: async (response: WebMessage) => response.getResultOrThrow() }) @@ -91,7 +103,7 @@ export const useServerApi = (settings: AppSettings) => { id: Endpoints.CreateId, method: 'PUT', path: () => `${get(nostrUrl)}?type=identity`, - onRequest: (identity:NostrIdentiy) => Promise.resolve(identity), + onRequest: (identity: NostrPubKey) => Promise.resolve(identity), onResponse: async (response: WebMessage<NostrEvent>) => response.getResultOrThrow() }) @@ -99,7 +111,7 @@ export const useServerApi = (settings: AppSettings) => { id: Endpoints.UpdateId, method: 'PATCH', path: () => `${get(nostrUrl)}?type=identity`, - onRequest: (identity:NostrIdentiy) => { + onRequest: (identity:NostrPubKey) => { const id = cloneDeep(identity) as any; delete id.Created; delete id.LastModified; @@ -121,7 +133,7 @@ export const useServerApi = (settings: AppSettings) => { id:Endpoints.Encrypt, method:'POST', path: () => `${get(nostrUrl)}?type=encrypt`, - onRequest: (data: NostrEvent) => Promise.resolve(data), + onRequest: (data: EncryptionRequest) => Promise.resolve(data), onResponse: async (response: WebMessage<string>) => response.getResultOrThrow() }) @@ -129,7 +141,7 @@ export const useServerApi = (settings: AppSettings) => { id:Endpoints.Decrypt, method:'POST', path: () => `${get(nostrUrl)}?type=decrypt`, - onRequest: (data: NostrEvent) => Promise.resolve(data), + onRequest: (data: EncryptionRequest) => Promise.resolve(data), onResponse: async (response: WebMessage<string>) => response.getResultOrThrow() }) diff --git a/extension/src/features/settings.ts b/extension/src/features/settings.ts index a67957c..059c2d2 100644 --- a/extension/src/features/settings.ts +++ b/extension/src/features/settings.ts @@ -19,7 +19,7 @@ import { configureApi, debugLog } from '@vnuge/vnlib.browser' import { readonly, ref, Ref } from "vue"; import { JsonObject } from "type-fest"; import { Watchable, useSingleSlotStorage } from "./types"; -import { BgRuntime, FeatureApi, optionsOnly, IFeatureExport, exportForegroundApi } from './framework' +import { BgRuntime, FeatureApi, optionsOnly, IFeatureExport, exportForegroundApi, popupAndOptionsOnly } from './framework' import { get, watchOnce } from "@vueuse/core"; export interface PluginConfig extends JsonObject { @@ -143,7 +143,7 @@ export const useSettingsApi = () : IFeatureExport<AppSettings, SettingsApi> =>{ return state.currentConfig.value }), - setDarkMode: optionsOnly(async (darkMode: boolean) => { + setDarkMode: popupAndOptionsOnly(async (darkMode: boolean) => { console.log('Setting dark mode to', darkMode, 'from', _darkMode.value) _darkMode.value = darkMode }), diff --git a/extension/src/features/types.ts b/extension/src/features/types.ts index bd0afee..a64be93 100644 --- a/extension/src/features/types.ts +++ b/extension/src/features/types.ts @@ -35,8 +35,18 @@ export interface TaggedNostrEvent extends NostrEvent { tags?: any[][] } -export interface EventMessage extends JsonObject { - readonly event: NostrEvent +export interface EncryptionRequest extends JsonObject { + readonly KeyId: string + /** + * The plaintext to encrypt or ciphertext + * to decrypt + */ + readonly content: string + /** + * The other peer's public key used + * for encryption + */ + readonly pubkey: string } export interface NostrRelay extends JsonObject { diff --git a/extension/src/messaging/index.ts b/extension/src/messaging/index.ts new file mode 100644 index 0000000..dfc5aee --- /dev/null +++ b/extension/src/messaging/index.ts @@ -0,0 +1,261 @@ +import uuid from 'tiny-uid' +import { isEqual } from 'lodash' +import { Runtime, runtime } from 'webextension-polyfill' +import { serializeError, isErrorLike, type ErrorObject, deserializeError } from 'serialize-error' +import type { JsonObject } from 'type-fest' + +export type ChannelContext = 'background' | 'content-script' | 'popup' | 'devtools' | 'options' +export type OnMessageCallback<T extends JsonObject> = (sender: ChannelContext, payload: any) => Promise<T> + +export interface ClientChannel { + sendMessage<TSource extends JsonObject, TResult extends JsonObject>(name: string, message: TSource): Promise<TResult> + disconnect(): void +} + +export interface ListenerChannel { + onMessage<T extends JsonObject>(name: string, onMessage: OnMessageCallback<T>): () => void + onDisconnected(cb: () => void): void +} + +export interface MessageChannel{ + openChannel(): ClientChannel + openOnMessageChannel(): ListenerChannel +} + +interface InternalChannelMessage{ + readonly transactionId: string + readonly payload: JsonObject + readonly srcContext: ChannelContext + readonly destContext: ChannelContext + readonly handlerName: string +} + +interface InternalChannelMessageResponse{ + readonly request: InternalChannelMessage + readonly response: JsonObject | undefined + readonly error?: ErrorObject +} + +interface SendMessageToken<T extends JsonObject> { + readonly request: InternalChannelMessage; + resolve(response: T): void; + reject(error: Error): void +} + +interface RxChannelState{ + addHandler(name:string, handler: OnMessageCallback<any>): () => void + removeHandler(name:string, handler: OnMessageCallback<any>): void + handlePortMessage(message: InternalChannelMessage, port: Runtime.Port): void + onDisconnected(): void +} + +export const createMessageChannel = (localContext: ChannelContext): MessageChannel => { + + const createRxChannel = (): RxChannelState => { + + const createPortResponse = (request: InternalChannelMessage, response?: JsonObject, error?: ErrorObject) + : InternalChannelMessageResponse => { + return { request, response, error } + } + + //Stores open messages + const handlerMap = new Map<string, OnMessageCallback<any>>() + + const handleMessageInternal = async (message: InternalChannelMessage): Promise<any> => { + //OnMessage hanlders will always respond to a destination context + if (!isEqual(message.destContext, localContext)) { + throw new Error(`Invalid destination context ${message.destContext}`) + } + switch (message.srcContext) { + case 'background': + throw new Error('Background context is not supported as a source') + case 'content-script': + case 'popup': + case 'devtools': + case 'options': + break; + default: + throw new Error(`Invalid source context ${message.srcContext}`) + } + + //Try to get the handler + const handler = handlerMap.get(message.handlerName) + if (handler === undefined) { + throw new Error(`No handler for ${message.handlerName}`) + } + + return handler(message.srcContext, message.payload); + } + + return { + addHandler(name: string, handler: OnMessageCallback<any>) { + handlerMap.set(name, handler) + //Return a function to remove the handler + return () => handlerMap.delete(name) + }, + removeHandler(name: string) { + handlerMap.delete(name) + }, + async handlePortMessage(message: InternalChannelMessage, port: Runtime.Port) { + + let isConnected = true + const onDisconnected = () => { + isConnected = false + } + + //Add disconnect handler so we can know if the port has disconnected + port.onDisconnect.addListener(onDisconnected) + + try { + + //Invoke internal message handler and convert to promise response + const response = await handleMessageInternal(message); + + if (!isConnected) { + return + } + + port.postMessage( + createPortResponse(message, response) + ) + } + catch (err: unknown) { + if (!isConnected) { + return + } + + //try to serialize the error + const handlerError = isErrorLike(err) ? serializeError(err) : err as ErrorObject + + //Send the error back to the port + port.postMessage( + createPortResponse(message, undefined, handlerError) + ) + } + finally { + //remove disconnect handler + port.onDisconnect.removeListener(onDisconnected) + } + }, + onDisconnected() { + } + } + } + + const createTxChannel = (destContext: ChannelContext) => { + + const handlerMap = new Map<string, SendMessageToken<any>>() + + return { + sendMessage: (port: Runtime.Port) => { + return <T extends JsonObject>(name: string, message: JsonObject): Promise<T> => { + //unique transaction id for message, used to match in response map + const transactionId = uuid(32) + + //Create itnernal request wrapper + const request: InternalChannelMessage = { + transactionId, + payload: message, + srcContext: localContext, + destContext, + handlerName: name + } + + //Create promise + const promise = new Promise<T>((resolve, reject) => { + //Add to handler map + handlerMap.set(transactionId, { request, resolve, reject }) + }) + + //Send request + port.postMessage(request) + + //Return promise + return promise + } + }, + onMessage: (message: InternalChannelMessageResponse) => { + const { transactionId } = message.request + + //Get the handler + const handler = handlerMap.get(transactionId) + if (handler === undefined) { + throw new Error(`No waiting response handler for ${transactionId}`) + } + + //Remove the handler + handlerMap.delete(transactionId) + + //Check for error + if (message.error !== undefined) { + //Deserialize error + const err = deserializeError(message.error) + handler.reject(err) + } + else { + handler.resolve(message.response) + } + }, + onReconnect: (port: Runtime.Port) => { + //resend pending messages + handlerMap.forEach((token, _) => port.postMessage(token.request)) + } + } + } + + return { + openChannel(): ClientChannel { + //Open the transmission channel + const { sendMessage, onReconnect, onMessage } = createTxChannel('background'); + + let port: Runtime.Port; + + /** + * Creates a persistent connection to the background script. + * When the port closes, it is reopend and all pending messages + * are resent + */ + const connect = () => { + port = runtime.connect() + port.onMessage.addListener(onMessage) + //reconnect on disconnect + port.onDisconnect.addListener(connect) + //resend pending messages + onReconnect(port) + } + + if (localContext === 'background') { + throw new Error('Send channels are not currently supported by ') + } + + connect() + + return { + //Init send-message handler + sendMessage: sendMessage(port!), + disconnect: port!.disconnect + } + }, + openOnMessageChannel(): ListenerChannel { + const { addHandler, handlePortMessage, onDisconnected } = createRxChannel() + + const onDisconnectedHandlers = new Set<() => void>() + + //Listen for new connections + runtime.onConnect.addListener((port: Runtime.Port) => { + port.onMessage.addListener(handlePortMessage); + port.onDisconnect.addListener(onDisconnected); + //Call all local handlers on on disconnect + port.onDisconnect.addListener(() => { + onDisconnectedHandlers.forEach(cb => cb()) + }) + }) + + return { + onMessage: addHandler, + //add to onDisconnectedHandlers + onDisconnected: onDisconnectedHandlers.add, + } + } + } +}
\ No newline at end of file diff --git a/extension/src/webext-bridge/LICENSE.txt b/extension/src/webext-bridge/LICENSE.txt deleted file mode 100644 index b3236f3..0000000 --- a/extension/src/webext-bridge/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2017 Neek Sandhu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE.
\ No newline at end of file diff --git a/extension/src/webext-bridge/README.md b/extension/src/webext-bridge/README.md deleted file mode 100644 index de14bac..0000000 --- a/extension/src/webext-bridge/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# zikaari/webext-bridge - -The source code in this directory/subdirectory is mostly not my own. The original project can be found here [webext-bridge](https://github.com/zikaari/webext-bridge). - -Because of bundling [issues](https://github.com/zikaari/webext-bridge/issues/69), I had to copy the source code here and make some changes to it. The original project is licensed under the MIT license. - -## License -As per AGPLv3 the original license and copyright can be found in this directory as this ia a dirived work. - -This file is part of NVault, licensed under AGPLv3. -NVault is a derivative work of webext-bridge, which is licensed under the MIT license. - -The MIT License (MIT) - -Copyright (c) 2017 Neek Sandhu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -### Changes - - Removed all npm package related files - - Changed port creation and local state to be function scoped - - removed files for file-based-exports such as options.ts and popup.ts diff --git a/extension/src/webext-bridge/index.ts b/extension/src/webext-bridge/index.ts deleted file mode 100644 index 6e87f07..0000000 --- a/extension/src/webext-bridge/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './types' -export { isInternalEndpoint } from './internal/is-internal-endpoint' -export { parseEndpoint } from './internal/endpoint' -export { createPort, createBackgroundPort } from './ports'
\ No newline at end of file diff --git a/extension/src/webext-bridge/internal/connection-args.ts b/extension/src/webext-bridge/internal/connection-args.ts deleted file mode 100644 index 9b93e19..0000000 --- a/extension/src/webext-bridge/internal/connection-args.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { EndpointFingerprint } from './endpoint-fingerprint' - -export interface ConnectionArgs { - endpointName: string - fingerprint: EndpointFingerprint -} - -const isValidConnectionArgs = ( - args: unknown, - requiredKeys: (keyof ConnectionArgs)[] = ['endpointName', 'fingerprint'], -): args is ConnectionArgs => - typeof args === 'object' - && args !== null - && requiredKeys.every(k => k in args) - -export const encodeConnectionArgs = (args: ConnectionArgs) => { - if (!isValidConnectionArgs(args)) - throw new TypeError('Invalid connection args') - - return JSON.stringify(args) -} - -export const decodeConnectionArgs = (encodedArgs: string): ConnectionArgs => { - try { - const args = JSON.parse(encodedArgs) - return isValidConnectionArgs(args) ? args : null - } - catch (error) { - return null - } -} diff --git a/extension/src/webext-bridge/internal/delivery-logger.ts b/extension/src/webext-bridge/internal/delivery-logger.ts deleted file mode 100644 index 395f035..0000000 --- a/extension/src/webext-bridge/internal/delivery-logger.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { InternalMessage } from '../types' -import type { EndpointFingerprint } from './endpoint-fingerprint' - -export interface DeliveryReceipt { - message: InternalMessage - to: EndpointFingerprint - from: { - endpointId: string - fingerprint: EndpointFingerprint - } -} - -export const createDeliveryLogger = () => { - let logs: ReadonlyArray<DeliveryReceipt> = [] - - return { - add: (...receipts: DeliveryReceipt[]) => { - logs = [...logs, ...receipts] - }, - remove: (message: string | DeliveryReceipt[]) => { - logs - = typeof message === 'string' - ? logs.filter(receipt => receipt.message.transactionId !== message) - : logs.filter(receipt => !message.includes(receipt)) - }, - entries: () => logs, - } -} diff --git a/extension/src/webext-bridge/internal/endpoint-fingerprint.ts b/extension/src/webext-bridge/internal/endpoint-fingerprint.ts deleted file mode 100644 index fe3cc24..0000000 --- a/extension/src/webext-bridge/internal/endpoint-fingerprint.ts +++ /dev/null @@ -1,5 +0,0 @@ -import uid from 'tiny-uid' - -export type EndpointFingerprint = `uid::${string}` - -export const createFingerprint = (): EndpointFingerprint => `uid::${uid(7)}` diff --git a/extension/src/webext-bridge/internal/endpoint-runtime.ts b/extension/src/webext-bridge/internal/endpoint-runtime.ts deleted file mode 100644 index 67b4fe0..0000000 --- a/extension/src/webext-bridge/internal/endpoint-runtime.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { JsonValue } from 'type-fest' -import uuid from 'tiny-uid' -import { serializeError } from 'serialize-error' -import type { - BridgeMessage, - DataTypeKey, - Destination, - GetDataType, - GetReturnType, - InternalMessage, - OnMessageCallback, - RuntimeContext, -} from '../types' -import { parseEndpoint } from './endpoint' - -export interface EndpointRuntime { - sendMessage: < - ReturnType extends JsonValue, - K extends DataTypeKey = DataTypeKey, - >( - messageID: K, - data: GetDataType<K, JsonValue>, - destination?: Destination - ) => Promise<GetReturnType<K, ReturnType>> - onMessage: <Data extends JsonValue, K extends DataTypeKey = DataTypeKey>( - messageID: K, - callback: OnMessageCallback<GetDataType<K, Data>, GetReturnType<K, any>> - ) => (() => void) - /** - * @internal - */ - handleMessage: (message: InternalMessage) => void - endTransaction: (transactionID: string) => void -} - -export const createEndpointRuntime = ( - thisContext: RuntimeContext, - routeMessage: (msg: InternalMessage) => void, - localMessage?: (msg: InternalMessage) => void, -): EndpointRuntime => { - const runtimeId = uuid() - const openTransactions = new Map< - string, - { resolve: (v: unknown) => void; reject: (e: unknown) => void } - >() - const onMessageListeners = new Map<string, OnMessageCallback<JsonValue>>() - - const handleMessage = (message: InternalMessage) => { - if ( - message.destination.context === thisContext - && !message.destination.frameId - && !message.destination.tabId - ) { - localMessage?.(message) - - const { transactionId, messageID, messageType } = message - - const handleReply = () => { - const transactionP = openTransactions.get(transactionId) - if (transactionP) { - const { err, data } = message - if (err) { - const dehydratedErr = err as Record<string, string> - const errCtr = self[dehydratedErr.name] as any - const hydratedErr = new ( - typeof errCtr === 'function' ? errCtr : Error - )(dehydratedErr.message) - - // eslint-disable-next-line no-restricted-syntax - for (const prop in dehydratedErr) - hydratedErr[prop] = dehydratedErr[prop] - - transactionP.reject(hydratedErr) - } - else { - transactionP.resolve(data) - } - openTransactions.delete(transactionId) - } - } - - const handleNewMessage = async() => { - let reply: JsonValue | void - let err: Error - let noHandlerFoundError = false - - try { - const cb = onMessageListeners.get(messageID) - if (typeof cb === 'function') { - // eslint-disable-next-line n/no-callback-literal - reply = await cb({ - sender: message.origin, - id: messageID, - data: message.data, - timestamp: message.timestamp, - } as BridgeMessage<JsonValue>) - } - else { - noHandlerFoundError = true - throw new Error( - `[webext-bridge] No handler registered in '${thisContext}' to accept messages with id '${messageID}'`, - ) - } - } - catch (error) { - err = error - } - finally { - if (err) message.err = serializeError(err) - - handleMessage({ - ...message, - messageType: 'reply', - data: reply, - origin: { context: thisContext, tabId: null }, - destination: message.origin, - hops: [], - }) - - if (err && !noHandlerFoundError) - // eslint-disable-next-line no-unsafe-finally - throw reply - } - } - - switch (messageType) { - case 'reply': - return handleReply() - case 'message': - return handleNewMessage() - } - } - - message.hops.push(`${thisContext}::${runtimeId}`) - - return routeMessage(message) - } - - return { - handleMessage, - endTransaction: (transactionID) => { - const transactionP = openTransactions.get(transactionID) - transactionP?.reject('Transaction was ended before it could complete') - openTransactions.delete(transactionID) - }, - sendMessage: (messageID, data, destination = 'background') => { - const endpoint - = typeof destination === 'string' - ? parseEndpoint(destination) - : destination - const errFn = 'Bridge#sendMessage ->' - - if (!endpoint.context) { - throw new TypeError( - `${errFn} Destination must be any one of known destinations`, - ) - } - - return new Promise((resolve, reject) => { - const payload: InternalMessage = { - messageID, - data, - destination: endpoint, - messageType: 'message', - transactionId: uuid(), - origin: { context: thisContext, tabId: null }, - hops: [], - timestamp: Date.now(), - } - - openTransactions.set(payload.transactionId, { resolve, reject }) - - try { - handleMessage(payload) - } - catch (error) { - openTransactions.delete(payload.transactionId) - reject(error) - } - }) - }, - onMessage: (messageID, callback) => { - onMessageListeners.set(messageID, callback) - return () => onMessageListeners.delete(messageID) - }, - } -} diff --git a/extension/src/webext-bridge/internal/endpoint.ts b/extension/src/webext-bridge/internal/endpoint.ts deleted file mode 100644 index 0c271f2..0000000 --- a/extension/src/webext-bridge/internal/endpoint.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Endpoint, RuntimeContext } from '../types' - -const ENDPOINT_RE = /^((?:background$)|devtools|popup|options|content-script|window)(?:@(\d+)(?:\.(\d+))?)?$/ - -export const parseEndpoint = (endpoint: string): Endpoint => { - const [, context, tabId, frameId] = endpoint.match(ENDPOINT_RE) || [] - - return { - context: context as RuntimeContext, - tabId: +tabId, - frameId: frameId ? +frameId : undefined, - } -} - -export const formatEndpoint = ({ context, tabId, frameId }: Endpoint): string => { - if (['background', 'popup', 'options'].includes(context)) - return context - - return `${context}@${tabId}${frameId ? `.${frameId}` : ''}` -} diff --git a/extension/src/webext-bridge/internal/is-internal-endpoint.ts b/extension/src/webext-bridge/internal/is-internal-endpoint.ts deleted file mode 100644 index 5e6ab4c..0000000 --- a/extension/src/webext-bridge/internal/is-internal-endpoint.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Endpoint, RuntimeContext } from '../types' - -const internalEndpoints: RuntimeContext[] = ['background', 'devtools', 'content-script', 'options', 'popup'] - -export const isInternalEndpoint = ({ context: ctx }: Endpoint): boolean => internalEndpoints.includes(ctx) diff --git a/extension/src/webext-bridge/internal/message-port.ts b/extension/src/webext-bridge/internal/message-port.ts deleted file mode 100644 index 204c11a..0000000 --- a/extension/src/webext-bridge/internal/message-port.ts +++ /dev/null @@ -1,52 +0,0 @@ -let promise: Promise<MessagePort> - -/** - * Returns a MessagePort for one-on-one communication - * - * Depending on which context's code runs first, either an incoming port from the other side - * is accepted OR a port will be offered, which the other side will then accept. - */ -export const getMessagePort = ( - thisContext: 'window' | 'content-script', - namespace: string, - onMessage: (e: MessageEvent<any>) => void, -): Promise<MessagePort> => ( - promise ??= new Promise((resolve) => { - const acceptMessagingPort = (event: MessageEvent) => { - const { data: { cmd, scope, context }, ports } = event - if (cmd === 'webext-port-offer' && scope === namespace && context !== thisContext) { - window.removeEventListener('message', acceptMessagingPort) - ports[0].onmessage = onMessage - ports[0].postMessage('port-accepted') - return resolve(ports[0]) - } - } - - const offerMessagingPort = () => { - const channel = new MessageChannel() - channel.port1.onmessage = (event: MessageEvent) => { - if (event.data === 'port-accepted') { - window.removeEventListener('message', acceptMessagingPort) - return resolve(channel.port1) - } - - onMessage?.(event) - } - - window.postMessage({ - cmd: 'webext-port-offer', - scope: namespace, - context: thisContext, - }, '*', [channel.port2]) - } - - window.addEventListener('message', acceptMessagingPort) - - // one of the contexts needs to be offset by at least 1 tick to prevent a race condition - // where both of them are offering, and then also accepting the port at the same time - if (thisContext === 'window') - setTimeout(offerMessagingPort, 0) - else - offerMessagingPort() - }) -) diff --git a/extension/src/webext-bridge/internal/persistent-port.ts b/extension/src/webext-bridge/internal/persistent-port.ts deleted file mode 100644 index 2281c68..0000000 --- a/extension/src/webext-bridge/internal/persistent-port.ts +++ /dev/null @@ -1,126 +0,0 @@ -import browser from 'webextension-polyfill' -import type { Runtime } from 'webextension-polyfill' -import type { InternalMessage } from '../types' -import { createFingerprint } from './endpoint-fingerprint' -import type { QueuedMessage } from './types' -import { encodeConnectionArgs } from './connection-args' -import { createDeliveryLogger } from './delivery-logger' -import type { StatusMessage } from './port-message' -import { PortMessage } from './port-message' - -/** - * Manfiest V3 extensions can have their service worker terminated at any point - * by the browser. That termination of service worker also terminates any messaging - * porta created by other parts of the extension. This class is a wrapper around the - * built-in Port object that re-instantiates the port connection everytime it gets - * suspended - */ -export const createPersistentPort = (name = '') => { - const fingerprint = createFingerprint() - let port: Runtime.Port - let undeliveredQueue: ReadonlyArray<QueuedMessage> = [] - const pendingResponses = createDeliveryLogger() - const onMessageListeners = new Set< - (message: InternalMessage, port: Runtime.Port) => void - >() - const onFailureListeners = new Set<(message: InternalMessage) => void>() - - const handleMessage = (msg: StatusMessage, port: Runtime.Port) => { - switch (msg.status) { - case 'undeliverable': - if ( - !undeliveredQueue.some( - m => m.message.messageID === msg.message.messageID, - ) - ) { - undeliveredQueue = [ - ...undeliveredQueue, - { - message: msg.message, - resolvedDestination: msg.resolvedDestination, - }, - ] - } - - return - - case 'deliverable': - undeliveredQueue = undeliveredQueue.reduce((acc, queuedMsg) => { - if (queuedMsg.resolvedDestination === msg.deliverableTo) { - PortMessage.toBackground(port, { - type: 'deliver', - message: queuedMsg.message, - }) - - return acc - } - - return [...acc, queuedMsg] - }, [] as ReadonlyArray<QueuedMessage>) - - return - - case 'delivered': - if (msg.receipt.message.messageType === 'message') - pendingResponses.add(msg.receipt) - - return - - case 'incoming': - if (msg.message.messageType === 'reply') - pendingResponses.remove(msg.message.messageID) - - onMessageListeners.forEach(cb => cb(msg.message, port)) - - return - - case 'terminated': { - const rogueMsgs = pendingResponses - .entries() - .filter(receipt => msg.fingerprint === receipt.to) - pendingResponses.remove(rogueMsgs) - rogueMsgs.forEach(({ message }) => - onFailureListeners.forEach(cb => cb(message)), - ) - } - } - } - - const connect = () => { - port = browser.runtime.connect({ - name: encodeConnectionArgs({ - endpointName: name, - fingerprint, - }), - }) - port.onMessage.addListener(handleMessage) - port.onDisconnect.addListener(connect) - - PortMessage.toBackground(port, { - type: 'sync', - pendingResponses: pendingResponses.entries(), - pendingDeliveries: [ - ...new Set( - undeliveredQueue.map(({ resolvedDestination }) => resolvedDestination), - ), - ], - }) - } - - connect() - - return { - onFailure(cb: (message: InternalMessage) => void) { - onFailureListeners.add(cb) - }, - onMessage(cb: (message: InternalMessage) => void): void { - onMessageListeners.add(cb) - }, - postMessage(message: any): void { - PortMessage.toBackground(port, { - type: 'deliver', - message, - }) - }, - } -} diff --git a/extension/src/webext-bridge/internal/port-message.ts b/extension/src/webext-bridge/internal/port-message.ts deleted file mode 100644 index 056e219..0000000 --- a/extension/src/webext-bridge/internal/port-message.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Runtime } from 'webextension-polyfill' -import type { InternalMessage } from '../types' -import type { DeliveryReceipt } from './delivery-logger' -import type { EndpointFingerprint } from './endpoint-fingerprint' - -export type StatusMessage = - | { - status: 'undeliverable' - message: InternalMessage - resolvedDestination: string - } - | { - status: 'deliverable' - deliverableTo: string - } - | { - status: 'delivered' - receipt: DeliveryReceipt - } - | { - status: 'incoming' - message: InternalMessage - } - | { - status: 'terminated' - fingerprint: EndpointFingerprint - } - -export type RequestMessage = - | { - type: 'sync' - pendingResponses: ReadonlyArray<DeliveryReceipt> - pendingDeliveries: ReadonlyArray<string> - } - | { - type: 'deliver' - message: InternalMessage - } - -export class PortMessage { - static toBackground(port: Runtime.Port, message: RequestMessage) { - return port.postMessage(message) - } - - static toExtensionContext(port: Runtime.Port, message: StatusMessage) { - return port.postMessage(message) - } -} diff --git a/extension/src/webext-bridge/internal/post-message.ts b/extension/src/webext-bridge/internal/post-message.ts deleted file mode 100644 index 9db4424..0000000 --- a/extension/src/webext-bridge/internal/post-message.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { InternalMessage } from '../types' -import { getMessagePort } from './message-port' - -export interface EndpointWontRespondError { - type: 'error' - transactionID: string -} - -export const usePostMessaging = (thisContext: 'window' | 'content-script') => { - let allocatedNamespace: string - let messagingEnabled = false - let onMessageCallback: ( - msg: InternalMessage | EndpointWontRespondError - ) => void - let portP: Promise<MessagePort> - - return { - enable: () => (messagingEnabled = true), - onMessage: (cb: typeof onMessageCallback) => (onMessageCallback = cb), - postMessage: async(msg: InternalMessage | EndpointWontRespondError) => { - if (thisContext !== 'content-script' && thisContext !== 'window') - throw new Error('Endpoint does not use postMessage') - - if (!messagingEnabled) - throw new Error('Communication with window has not been allowed') - - ensureNamespaceSet(allocatedNamespace) - - return (await portP).postMessage(msg) - }, - setNamespace: (nsps: string) => { - if (allocatedNamespace) - throw new Error('Namespace once set cannot be changed') - - allocatedNamespace = nsps - portP = getMessagePort(thisContext, nsps, ({ data }) => - onMessageCallback?.(data), - ) - }, - } -} - -function ensureNamespaceSet(namespace: string) { - if (typeof namespace !== 'string' || namespace.trim().length === 0) { - throw new Error( - 'webext-bridge uses window.postMessage to talk with other "window"(s) for message routing' - + 'which is global/conflicting operation in case there are other scripts using webext-bridge. ' - + 'Call Bridge#setNamespace(nsps) to isolate your app. Example: setNamespace(\'com.facebook.react-devtools\'). ' - + 'Make sure to use same namespace across all your scripts whereever window.postMessage is likely to be used`', - ) - } -} diff --git a/extension/src/webext-bridge/internal/stream.ts b/extension/src/webext-bridge/internal/stream.ts deleted file mode 100644 index 54cee9d..0000000 --- a/extension/src/webext-bridge/internal/stream.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { createNanoEvents } from 'nanoevents' -import uuid from 'tiny-uid' -import type { Emitter } from 'nanoevents' -import type { JsonValue } from 'type-fest' -import type { Endpoint, HybridUnsubscriber, RuntimeContext, StreamInfo } from '../types' -import type { EndpointRuntime } from './endpoint-runtime' -import { parseEndpoint } from './endpoint' - -/** - * Built on top of Bridge. Nothing much special except that Stream allows - * you to create a namespaced scope under a channel name of your choice - * and allows continuous e2e communication, with less possibility of - * conflicting messageId's, since streams are strictly scoped. - */ -export class Stream { - private static initDone = false - private static openStreams: Map<string, Stream> = new Map() - - private emitter: Emitter = createNanoEvents() - private isClosed = false - constructor(private endpointRuntime: EndpointRuntime, private streamInfo: StreamInfo) { - if (!Stream.initDone) { - endpointRuntime.onMessage<{ streamId: string; action: 'transfer' | 'close'; streamTransfer: JsonValue }, string>('__crx_bridge_stream_transfer__', (msg) => { - const { streamId, streamTransfer, action } = msg.data - const stream = Stream.openStreams.get(streamId) - if (stream && !stream.isClosed) { - if (action === 'transfer') - stream.emitter.emit('message', streamTransfer) - - if (action === 'close') { - Stream.openStreams.delete(streamId) - stream.handleStreamClose() - } - } - }) - Stream.initDone = true - } - - Stream.openStreams.set(this.streamInfo.streamId, this) - } - - /** - * Returns stream info - */ - public get info(): StreamInfo { - return this.streamInfo - } - - /** - * Sends a message to other endpoint. - * Will trigger onMessage on the other side. - * - * Warning: Before sending sensitive data, verify the endpoint using `stream.info.endpoint.isInternal()` - * The other side could be malicious webpage speaking same language as webext-bridge - * @param msg - */ - public send(msg?: JsonValue): void { - if (this.isClosed) - throw new Error('Attempting to send a message over closed stream. Use stream.onClose(<callback>) to keep an eye on stream status') - - this.endpointRuntime.sendMessage('__crx_bridge_stream_transfer__', { - streamId: this.streamInfo.streamId, - streamTransfer: msg, - action: 'transfer', - }, this.streamInfo.endpoint) - } - - /** - * Closes the stream. - * Will trigger stream.onClose(<callback>) on both endpoints. - * If needed again, spawn a new Stream, as this instance cannot be re-opened - * @param msg - */ - public close(msg?: JsonValue): void { - if (msg) - this.send(msg) - - this.handleStreamClose() - - this.endpointRuntime.sendMessage('__crx_bridge_stream_transfer__', { - streamId: this.streamInfo.streamId, - streamTransfer: null, - action: 'close', - }, this.streamInfo.endpoint) - } - - /** - * Registers a callback to fire whenever other endpoint sends a message - * @param callback - */ - public onMessage<T extends JsonValue>(callback: (msg?: T) => void): HybridUnsubscriber { - return this.getDisposable('message', callback) - } - - /** - * Registers a callback to fire whenever stream.close() is called on either endpoint - * @param callback - */ - public onClose<T extends JsonValue>(callback: (msg?: T) => void): HybridUnsubscriber { - return this.getDisposable('closed', callback) - } - - private handleStreamClose = () => { - if (!this.isClosed) { - this.isClosed = true - this.emitter.emit('closed', true) - this.emitter.events = {} - } - } - - private getDisposable(event: string, callback: () => void): HybridUnsubscriber { - const off = this.emitter.on(event, callback) - - return Object.assign(off, { - dispose: off, - close: off, - }) - } -} - -export const createStreamWirings = (endpointRuntime: EndpointRuntime) => { - const openStreams = new Map<string, Stream>() - const onOpenStreamCallbacks = new Map<string, (stream: Stream) => void>() - const streamyEmitter = createNanoEvents() - - endpointRuntime.onMessage<{ channel: string; streamId: string }, string>('__crx_bridge_stream_open__', (message) => { - return new Promise((resolve) => { - const { sender, data } = message - const { channel } = data - let watching = false - let off = () => { } - - const readyup = () => { - const callback = onOpenStreamCallbacks.get(channel) - - if (typeof callback === 'function') { - callback(new Stream(endpointRuntime, { ...data, endpoint: sender })) - if (watching) - off() - - resolve(true) - } - else if (!watching) { - watching = true - off = streamyEmitter.on('did-change-stream-callbacks', readyup) - } - } - - readyup() - }) - }) - - async function openStream(channel: string, destination: RuntimeContext | Endpoint | string): Promise<Stream> { - if (openStreams.has(channel)) - throw new Error('webext-bridge: A Stream is already open at this channel') - - const endpoint = typeof destination === 'string' ? parseEndpoint(destination) : destination - - const streamInfo: StreamInfo = { streamId: uuid(), channel, endpoint } - const stream = new Stream(endpointRuntime, streamInfo) - stream.onClose(() => openStreams.delete(channel)) - await endpointRuntime.sendMessage('__crx_bridge_stream_open__', streamInfo as unknown as JsonValue, endpoint) - openStreams.set(channel, stream) - return stream - } - - function onOpenStreamChannel(channel: string, callback: (stream: Stream) => void): void { - if (onOpenStreamCallbacks.has(channel)) - throw new Error('webext-bridge: This channel has already been claimed. Stream allows only one-on-one communication') - - onOpenStreamCallbacks.set(channel, callback) - streamyEmitter.emit('did-change-stream-callbacks') - } - - return { - openStream, - onOpenStreamChannel, - } -} diff --git a/extension/src/webext-bridge/internal/types.ts b/extension/src/webext-bridge/internal/types.ts deleted file mode 100644 index 2063adb..0000000 --- a/extension/src/webext-bridge/internal/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { InternalMessage } from '../types' - -export interface QueuedMessage { - resolvedDestination: string - message: InternalMessage -} diff --git a/extension/src/webext-bridge/ports.ts b/extension/src/webext-bridge/ports.ts deleted file mode 100644 index 8edd04e..0000000 --- a/extension/src/webext-bridge/ports.ts +++ /dev/null @@ -1,391 +0,0 @@ -import browser, { type Runtime } from 'webextension-polyfill' -import { formatEndpoint, parseEndpoint } from './internal/endpoint' -import { decodeConnectionArgs } from './internal/connection-args' -import { PortMessage } from './internal/port-message' -import { createEndpointRuntime } from './internal/endpoint-runtime' -import { createStreamWirings } from './internal/stream' -import { createPersistentPort } from './internal/persistent-port' -import { usePostMessaging } from './internal/post-message' -import { createFingerprint, type EndpointFingerprint } from './internal/endpoint-fingerprint' -import { createDeliveryLogger, type DeliveryReceipt } from './internal/delivery-logger' -import type { RequestMessage } from './internal/port-message' -import type { InternalMessage, RuntimeContext } from './types' - -const createContentScriptPort = () => { - - const win = usePostMessaging('content-script') - const port = createPersistentPort() - const endpointRuntime = createEndpointRuntime('content-script', (message) => { - if (message.destination.context === 'window') win.postMessage(message) - else port.postMessage(message) - }) - - win.onMessage((message: InternalMessage) => { - // a message event inside `content-script` means a script inside `window` dispatched it to be forwarded - // so we're making sure that the origin is not tampered (i.e script is not masquerading it's true identity) - message.origin = { - context: 'window', - tabId: null, - } - - endpointRuntime.handleMessage(message) - }) - - port.onMessage(endpointRuntime.handleMessage) - - port.onFailure((message) => { - if (message.origin.context === 'window') { - win.postMessage({ - type: 'error', - transactionID: message.transactionId, - }) - - return - } - - endpointRuntime.endTransaction(message.transactionId) - }) - - return { - sendMessage: endpointRuntime.sendMessage, - } -} - -export const createPort = (name: RuntimeContext) => { - - switch (name) { - case 'content-script': - return createContentScriptPort(); - default: - break; - } - - const port = createPersistentPort(name) - - const endpointRuntime = createEndpointRuntime( - name, - message => port.postMessage(message), - ) - - port.onMessage(endpointRuntime.handleMessage) - - return { - ...endpointRuntime, - ...createStreamWirings(endpointRuntime) - } -} - -interface PortConnection { - port: Runtime.Port - fingerprint: EndpointFingerprint -} - -export const createBackgroundPort = () =>{ - const pendingResponses = createDeliveryLogger() - const connMap = new Map<string, PortConnection>() - const oncePortConnectedCbs = new Map<string, Set<() => void>>() - const onceSessionEndCbs = new Map<EndpointFingerprint, Set<() => void>>() - - const oncePortConnected = (endpointName: string, cb: () => void) => { - oncePortConnectedCbs.set( - endpointName, - (oncePortConnectedCbs.get(endpointName) || new Set()).add(cb), - ) - - return () => { - const su = oncePortConnectedCbs.get(endpointName) - if (su?.delete(cb) && su?.size === 0) - oncePortConnectedCbs.delete(endpointName) - } - } - - const onceSessionEnded = ( - sessionFingerprint: EndpointFingerprint, - cb: () => void, - ) => { - onceSessionEndCbs.set( - sessionFingerprint, - (onceSessionEndCbs.get(sessionFingerprint) || new Set()).add(cb), - ) - } - - const notifyEndpoint = (endpoint: string) => ({ - withFingerprint: (fingerprint: EndpointFingerprint) => { - const nextChain = <T>(v: T) => ({ and: () => v }) - - const notifications = { - aboutIncomingMessage: (message: InternalMessage) => { - const recipient = connMap.get(endpoint) - - PortMessage.toExtensionContext(recipient.port, { - status: 'incoming', - message, - }) - - return nextChain(notifications) - }, - - aboutSuccessfulDelivery: (receipt: DeliveryReceipt) => { - const sender = connMap.get(endpoint) - PortMessage.toExtensionContext(sender.port, { - status: 'delivered', - receipt, - }) - - return nextChain(notifications) - }, - - aboutMessageUndeliverability: ( - resolvedDestination: string, - message: InternalMessage, - ) => { - const sender = connMap.get(endpoint) - if (sender?.fingerprint === fingerprint) { - PortMessage.toExtensionContext(sender.port, { - status: 'undeliverable', - resolvedDestination, - message, - }) - } - - return nextChain(notifications) - }, - - whenDeliverableTo: (targetEndpoint: string) => { - const notifyDeliverability = () => { - const origin = connMap.get(endpoint) - if ( - origin?.fingerprint === fingerprint - && connMap.has(targetEndpoint) - ) { - PortMessage.toExtensionContext(origin.port, { - status: 'deliverable', - deliverableTo: targetEndpoint, - }) - - return true - } - } - - if (!notifyDeliverability()) { - const unsub = oncePortConnected(targetEndpoint, notifyDeliverability) - onceSessionEnded(fingerprint, unsub) - } - - return nextChain(notifications) - }, - - aboutSessionEnded: (endedSessionFingerprint: EndpointFingerprint) => { - const conn = connMap.get(endpoint) - if (conn?.fingerprint === fingerprint) { - PortMessage.toExtensionContext(conn.port, { - status: 'terminated', - fingerprint: endedSessionFingerprint, - }) - } - - return nextChain(notifications) - }, - } - - return notifications - }, - }) - - const sessFingerprint = createFingerprint() - - const endpointRuntime = createEndpointRuntime( - 'background', - (message) => { - if ( - message.origin.context === 'background' - && ['content-script', 'devtools '].includes(message.destination.context) - && !message.destination.tabId - ) { - throw new TypeError( - 'When sending messages from background page, use @tabId syntax to target specific tab', - ) - } - - const resolvedSender = formatEndpoint({ - ...message.origin, - ...(message.origin.context === 'window' && { context: 'content-script' }), - }) - - const resolvedDestination = formatEndpoint({ - ...message.destination, - ...(message.destination.context === 'window' && { - context: 'content-script', - }), - tabId: message.destination.tabId || message.origin.tabId, - }) - - // downstream endpoints are agnostic of these attributes, presence of these attrs will make them think the message is not intended for them - message.destination.tabId = null - message.destination.frameId = null - - const dest = () => connMap.get(resolvedDestination) - const sender = () => connMap.get(resolvedSender) - - const deliver = () => { - notifyEndpoint(resolvedDestination) - .withFingerprint(dest().fingerprint) - .aboutIncomingMessage(message) - - const receipt: DeliveryReceipt = { - message, - to: dest().fingerprint, - from: { - endpointId: resolvedSender, - fingerprint: sender()?.fingerprint, - }, - } - - if (message.messageType === 'message') pendingResponses.add(receipt) - - if (message.messageType === 'reply') - pendingResponses.remove(message.messageID) - - if (sender()) { - notifyEndpoint(resolvedSender) - .withFingerprint(sender().fingerprint) - .aboutSuccessfulDelivery(receipt) - } - } - - if (dest()?.port) { - deliver() - } - else if (message.messageType === 'message') { - if (message.origin.context === 'background') { - oncePortConnected(resolvedDestination, deliver) - } - else if (sender()) { - notifyEndpoint(resolvedSender) - .withFingerprint(sender().fingerprint) - .aboutMessageUndeliverability(resolvedDestination, message) - .and() - .whenDeliverableTo(resolvedDestination) - } - } - }, - (message) => { - const resolvedSender = formatEndpoint({ - ...message.origin, - ...(message.origin.context === 'window' && { context: 'content-script' }), - }) - - const sender = connMap.get(resolvedSender) - - const receipt: DeliveryReceipt = { - message, - to: sessFingerprint, - from: { - endpointId: resolvedSender, - fingerprint: sender.fingerprint, - }, - } - - notifyEndpoint(resolvedSender) - .withFingerprint(sender.fingerprint) - .aboutSuccessfulDelivery(receipt) - }, - ) - - browser.runtime.onConnect.addListener((incomingPort) => { - const connArgs = decodeConnectionArgs(incomingPort.name) - - if (!connArgs) return - - // all other contexts except 'content-script' are aware of, and pass their identity as name - connArgs.endpointName ||= formatEndpoint({ - context: 'content-script', - tabId: incomingPort.sender.tab.id, - frameId: incomingPort.sender.frameId, - }) - - // literal tab id in case of content script, however tab id of inspected page in case of devtools context - const { tabId: linkedTabId, frameId: linkedFrameId } = parseEndpoint( - connArgs.endpointName, - ) - - connMap.set(connArgs.endpointName, { - fingerprint: connArgs.fingerprint, - port: incomingPort, - }) - - oncePortConnectedCbs.get(connArgs.endpointName)?.forEach(cb => cb()) - oncePortConnectedCbs.delete(connArgs.endpointName) - - onceSessionEnded(connArgs.fingerprint, () => { - const rogueMsgs = pendingResponses - .entries() - .filter(pendingMessage => pendingMessage.to === connArgs.fingerprint) - pendingResponses.remove(rogueMsgs) - - rogueMsgs.forEach((rogueMessage) => { - if (rogueMessage.from.endpointId === 'background') { - endpointRuntime.endTransaction(rogueMessage.message.transactionId) - } - else { - notifyEndpoint(rogueMessage.from.endpointId) - .withFingerprint(rogueMessage.from.fingerprint) - .aboutSessionEnded(connArgs.fingerprint) - } - }) - }) - - incomingPort.onDisconnect.addListener(() => { - // sometimes previous content script's onDisconnect is called *after* the fresh content-script's - // onConnect. So without this fingerprint equality check, we would remove the new port from map - if ( - connMap.get(connArgs.endpointName)?.fingerprint === connArgs.fingerprint - ) - connMap.delete(connArgs.endpointName) - - onceSessionEndCbs.get(connArgs.fingerprint)?.forEach(cb => cb()) - onceSessionEndCbs.delete(connArgs.fingerprint) - }) - - incomingPort.onMessage.addListener((msg: RequestMessage) => { - if (msg.type === 'sync') { - const allActiveSessions = [...connMap.values()].map( - conn => conn.fingerprint, - ) - const stillPending = msg.pendingResponses.filter(fp => - allActiveSessions.includes(fp.to), - ) - - pendingResponses.add(...stillPending) - - msg.pendingResponses - .filter( - deliveryReceipt => !allActiveSessions.includes(deliveryReceipt.to), - ) - .forEach(deliveryReceipt => - notifyEndpoint(connArgs.endpointName) - .withFingerprint(connArgs.fingerprint) - .aboutSessionEnded(deliveryReceipt.to), - ) - - msg.pendingDeliveries.forEach(intendedDestination => - notifyEndpoint(connArgs.endpointName) - .withFingerprint(connArgs.fingerprint) - .whenDeliverableTo(intendedDestination), - ) - - return - } - - if (msg.type === 'deliver' && msg.message?.origin?.context) { - // origin tab ID is resolved from the port identifier (also prevent "MITM attacks" of extensions) - msg.message.origin.tabId = linkedTabId - msg.message.origin.frameId = linkedFrameId - - endpointRuntime.handleMessage(msg.message) - } - }) - }) - - return{ ...endpointRuntime } -}
\ No newline at end of file diff --git a/extension/src/webext-bridge/types.ts b/extension/src/webext-bridge/types.ts deleted file mode 100644 index d184a5c..0000000 --- a/extension/src/webext-bridge/types.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { JsonValue, Jsonify } from 'type-fest' - -export type RuntimeContext = - | 'devtools' - | 'background' - | 'popup' - | 'options' - | 'content-script' - | 'window' - -export interface Endpoint { - context: RuntimeContext - tabId: number - frameId?: number -} - -export interface BridgeMessage<T extends JsonValue> { - sender: Endpoint - id: string - data: T - timestamp: number -} - -export type OnMessageCallback<T extends JsonValue, R = void | JsonValue> = ( - message: BridgeMessage<T> -) => R | Promise<R> - -export interface InternalMessage { - origin: Endpoint - destination: Endpoint - transactionId: string - hops: string[] - messageID: string - messageType: 'message' | 'reply' - err?: JsonValue - data?: JsonValue | void - timestamp: number -} - -export interface StreamInfo { - streamId: string - channel: string - endpoint: Endpoint -} - -export interface HybridUnsubscriber { - (): void - dispose: () => void - close: () => void -} - -export type Destination = Endpoint | RuntimeContext | string - -declare const ProtocolWithReturnSymbol: unique symbol - -export interface ProtocolWithReturn<Data, Return> { - data: Jsonify<Data> - return: Jsonify<Return> - /** - * Type differentiator only. - */ - [ProtocolWithReturnSymbol]: true -} - -/** - * Extendable by user. - */ -export interface ProtocolMap { - // foo: { id: number, name: string } - // bar: ProtocolWithReturn<string, number> -} - -export type DataTypeKey = keyof ProtocolMap extends never - ? string - : keyof ProtocolMap - -export type GetDataType< - K extends DataTypeKey, - Fallback extends JsonValue = undefined, -> = K extends keyof ProtocolMap - ? ProtocolMap[K] extends (...args: infer Args) => any - ? Args['length'] extends 0 - ? undefined - : Args[0] - : ProtocolMap[K] extends ProtocolWithReturn<infer Data, any> - ? Data - : ProtocolMap[K] - : Fallback; - - -export type GetReturnType< - K extends DataTypeKey, - Fallback extends JsonValue = undefined -> = K extends keyof ProtocolMap - ? ProtocolMap[K] extends (...args: any[]) => infer R - ? R - : ProtocolMap[K] extends ProtocolWithReturn<any, infer Return> - ? Return - : void - : Fallback; |