diff --git a/frontend/controller/namespace.js b/frontend/controller/namespace.js index 3d147b5670..33417a9fa5 100644 --- a/frontend/controller/namespace.js +++ b/frontend/controller/namespace.js @@ -1,17 +1,16 @@ 'use strict' import sbp from '@sbp/sbp' -import Vue from 'vue' // NOTE: prefix groups with `group/` and users with `user/` ? sbp('sbp/selectors/register', { 'namespace/lookupCached': (name: string) => { const cache = sbp('state/vuex/state').namespaceLookups - return cache[name] ?? null + return cache?.[name] ?? null }, 'namespace/lookupReverseCached': (id: string) => { const cache = sbp('state/vuex/state').reverseNamespaceLookups - return cache[id] ?? null + return cache?.[id] ?? null }, 'namespace/lookup': (name: string, { skipCache }: { skipCache: boolean } = { skipCache: false }) => { if (!skipCache) { @@ -23,23 +22,6 @@ sbp('sbp/selectors/register', { return Promise.resolve(cached) } } - return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name/${encodeURIComponent(name)}`).then((r: Object) => { - if (!r.ok) { - console.warn(`namespace/lookup: ${r.status} for ${name}`) - if (r.status !== 404) { - throw new Error(`${r.status}: ${r.statusText}`) - } - return null - } - return r['text']() - }).then(value => { - if (value !== null) { - const cache = sbp('state/vuex/state').namespaceLookups - const reverseCache = sbp('state/vuex/state').reverseNamespaceLookups - Vue.set(cache, name, value) - Vue.set(reverseCache, value, name) - } - return value - }) + return sbp('sw-namespace/lookup', name, { skipCache }) } }) diff --git a/frontend/controller/service-worker.js b/frontend/controller/service-worker.js index 2259341d16..79f5e24b2d 100644 --- a/frontend/controller/service-worker.js +++ b/frontend/controller/service-worker.js @@ -1,10 +1,11 @@ 'use strict' -import sbp from '@sbp/sbp' import { PUBSUB_INSTANCE } from '@controller/instance-keys.js' -import { REQUEST_TYPE, PUSH_SERVER_ACTION_TYPE, PUBSUB_RECONNECTION_SUCCEEDED, createMessage } from '~/shared/pubsub.js' -import { HOURS_MILLIS } from '~/frontend/model/contracts/shared/time.js' +import sbp from '@sbp/sbp' import { PWA_INSTALLABLE } from '@utils/events.js' +import { HOURS_MILLIS } from '~/frontend/model/contracts/shared/time.js' +import { PUBSUB_RECONNECTION_SUCCEEDED, PUSH_SERVER_ACTION_TYPE, REQUEST_TYPE, createMessage } from '~/shared/pubsub.js' +import { deserializer } from '~/shared/serdes/index.js' const pwa = { deferredInstallPrompt: null, @@ -66,6 +67,11 @@ sbp('sbp/selectors/register', { sbp('service-worker/resubscribe-push', data.subscription) break } + case 'event': { + console.error('@@@EVENT RECEIVED', event.data.subtype, ...deserializer(event.data.data)) + sbp('okTurtles.events/emit', event.data.subtype, ...deserializer(event.data.data)) + break + } default: console.error('[sw] Received unknown message type from the service worker:', data) break diff --git a/frontend/controller/serviceworkers/sw-primary.js b/frontend/controller/serviceworkers/sw-primary.js index c68a1de449..2dacb4065d 100644 --- a/frontend/controller/serviceworkers/sw-primary.js +++ b/frontend/controller/serviceworkers/sw-primary.js @@ -1,5 +1,23 @@ 'use strict' +import { PROPOSAL_ARCHIVED } from '@model/contracts/shared/constants.js' +import '@sbp/okturtles.data' +import '@sbp/okturtles.eventqueue' +import '@sbp/okturtles.events' +import sbp from '@sbp/sbp' +import '~/frontend/controller/actions/index.js' +import setupChelonia from '~/frontend/setupChelonia.js' +import { LOGIN, LOGIN_ERROR, LOGOUT } from '~/frontend/utils/events.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' +import { CONTRACTS_MODIFIED, CONTRACT_IS_SYNCING, EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' +import { deserializer, serializer } from '~/shared/serdes/index.js' +import { ACCEPTED_GROUP, DELETED_CHATROOM, JOINED_CHATROOM, JOINED_GROUP, LEFT_CHATROOM, LEFT_GROUP, NAMESPACE_REGISTRATION, SWITCH_GROUP } from '../../utils/events.js' +import '~/frontend/controller/sw-namespace.js' + +deserializer.register(GIMessage) +deserializer.register(Secret) + // https://serviceworke.rs/message-relay_service-worker_doc.html // https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers // https://jakearchibald.com/2014/using-serviceworker-today/ @@ -7,6 +25,44 @@ // https://frontendian.co/service-workers // https://stackoverflow.com/a/49748437 => https://medium.com/@nekrtemplar/self-destroying-serviceworker-73d62921d717 => https://love2dev.com/blog/how-to-uninstall-a-service-worker/ +sbp('sbp/filters/global/add', (domain, selector, data) => { + // if (domainBlacklist[domain] || selectorBlacklist[selector]) return + console.debug(`[sw] [sbp] ${selector}`, data) +}); + +[EVENT_HANDLED, CONTRACTS_MODIFIED, CONTRACT_IS_SYNCING, LOGIN, LOGIN_ERROR, LOGOUT, ACCEPTED_GROUP, DELETED_CHATROOM, LEFT_CHATROOM, LEFT_GROUP, JOINED_CHATROOM, JOINED_GROUP, NAMESPACE_REGISTRATION, SWITCH_GROUP, PROPOSAL_ARCHIVED].forEach(et => { + sbp('okTurtles.events/on', et, (...args) => { + const { data } = serializer(args) + const message = { + type: 'event', + subtype: et, + data + } + self.clients.matchAll() + .then((clientList) => { + clientList.forEach((client) => { + client.postMessage(message) + }) + }) + }) +}) + +sbp('sbp/selectors/register', { + 'state/vuex/state': () => { + // TODO: Remove this selector once it's removed from contracts + return sbp('chelonia/rootState') + }, + 'state/vuex/reset': () => { + console.error('[sw] CALLED state/vuex/reset WHICH IS UNDEFINED') + }, + 'state/vuex/save': () => { + console.error('[sw] CALLED state/vuex/save WHICH IS UNDEFINED') + }, + 'state/vuex/commit': () => { + console.error('[sw] CALLED state/vuex/commit WHICH IS UNDEFINED') + } +}) + self.addEventListener('install', function (event) { console.debug('[sw] install') event.waitUntil(self.skipWaiting()) @@ -16,7 +72,7 @@ self.addEventListener('activate', function (event) { console.debug('[sw] activate') // 'clients.claim()' reference: https://web.dev/articles/service-worker-lifecycle#clientsclaim - event.waitUntil(self.clients.claim()) + event.waitUntil(setupChelonia().then(() => self.clients.claim())) }) self.addEventListener('fetch', function (event) { @@ -57,6 +113,17 @@ self.addEventListener('message', function (event) { case 'store-client-id': store.clientId = event.source.id break + case 'sbp': { + const port = event.data.port; + (async () => await sbp(...deserializer(event.data.data)))().then((r) => { + const { data, transferables } = serializer(r) + port.postMessage([true, data], transferables) + }).catch((e) => { + const { data, transferables } = serializer(e) + port.postMessage([false, data], transferables) + }) + break + } case 'ping': event.source.postMessage({ type: 'pong' }) break diff --git a/frontend/controller/sw-namespace.js b/frontend/controller/sw-namespace.js new file mode 100644 index 0000000000..00b63ac803 --- /dev/null +++ b/frontend/controller/sw-namespace.js @@ -0,0 +1,51 @@ +'use strict' + +import sbp from '@sbp/sbp' +import { NAMESPACE_REGISTRATION } from '../utils/events.js' + +// NOTE: prefix groups with `group/` and users with `user/` ? +sbp('sbp/selectors/register', { + 'namespace/lookupCached': (name: string) => { + const cache = sbp('chelonia/rootState').namespaceLookups + // 'cache' may be undefined when starting up or after calling chelonia/reset + return cache?.[name] ?? null + }, + 'namespace/lookupReverseCached': (id: string) => { + const cache = sbp('chelonia/rootState').reverseNamespaceLookups + return cache?.[id] ?? null + }, + 'namespace/lookup': (name: string, { skipCache }: { skipCache: boolean } = { skipCache: false }) => { + if (!skipCache) { + const cached = sbp('namespace/lookupCached', name) + if (cached) { + // Wrapping in a Promise to return a consistent type across all execution + // paths (next return is a Promise) + // This way we can call .then() on the result + return Promise.resolve(cached) + } + } + return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name/${encodeURIComponent(name)}`).then((r: Object) => { + if (!r.ok) { + console.warn(`namespace/lookup: ${r.status} for ${name}`) + if (r.status !== 404) { + throw new Error(`${r.status}: ${r.statusText}`) + } + return null + } + return r['text']() + }).then(value => { + if (value !== null) { + const reactiveSet = sbp('chelonia/config').reactiveSet + const rootState = sbp('chelonia/rootState') + if (!rootState.namespaceLookups) reactiveSet(rootState, 'namespaceLookups', Object.create(null)) + if (!rootState.reverseNamespaceLookups) reactiveSet(rootState, 'reverseNamespaceLookups', Object.create(null)) + const cache = rootState.namespaceLookups + const reverseCache = rootState.reverseNamespaceLookups + reactiveSet(cache, name, value) + reactiveSet(reverseCache, value, name) + sbp('okTurtles.events/emit', NAMESPACE_REGISTRATION, { name, value }) + } + return value + }) + } +}) diff --git a/frontend/main.js b/frontend/main.js index b54c7b28d3..e8affabb9f 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -11,12 +11,10 @@ import ALLOWED_URLS from '@view-utils/allowedUrls.js' import IdleVue from 'idle-vue' import { mapGetters, mapMutations, mapState } from 'vuex' import 'wicg-inert' -import '~/shared/domains/chelonia/chelonia.js' import { CONTRACT_IS_SYNCING } from '~/shared/domains/chelonia/events.js' import '~/shared/domains/chelonia/localSelectors.js' import { KV_KEYS } from './utils/constants.js' // import '~/shared/domains/chelonia/persistent-actions.js' // Commented out as persistentActions are not being used -import './controller/actions/index.js' import './controller/app/index.js' import './controller/backend.js' import './controller/namespace.js' @@ -24,7 +22,7 @@ import router from './controller/router.js' import './controller/service-worker.js' import { SETTING_CURRENT_USER } from './model/database.js' import store from './model/state.js' -import { KV_EVENT, LOGIN_COMPLETE, LOGIN_ERROR, LOGOUT, OFFLINE, ONLINE, RECONNECTING, RECONNECTION_FAILED, SWITCH_GROUP, THEME_CHANGE } from './utils/events.js' +import { KV_EVENT, LOGIN_COMPLETE, LOGIN_ERROR, LOGOUT, NAMESPACE_REGISTRATION, OFFLINE, ONLINE, RECONNECTING, RECONNECTION_FAILED, SWITCH_GROUP, THEME_CHANGE } from './utils/events.js' import AppStyles from './views/components/AppStyles.vue' import BannerGeneral from './views/components/banners/BannerGeneral.vue' import Modal from './views/components/modal/Modal.vue' @@ -35,14 +33,19 @@ import './views/utils/ui.js' import './views/utils/vError.js' import './views/utils/vFocus.js' // import './views/utils/vSafeHtml.js' // this gets imported by translations, which is part of common.js +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' +import { deserializer, serializer } from '~/shared/serdes/index.js' import notificationsMixin from './model/notifications/mainNotificationsMixin.js' import './model/notifications/periodicNotifications.js' -import setupChelonia from './setupChelonia.js' import FaviconBadge from './utils/faviconBadge.js' import './utils/touchInteractions.js' import { showNavMixin } from './views/utils/misc.js' import './views/utils/vStyle.js' +deserializer.register(GIMessage) +deserializer.register(Secret) + const { Vue, L, LError } = Common console.info('GI_VERSION:', process.env.GI_VERSION) @@ -99,10 +102,12 @@ async function startApp () { // TODO: [SW] The following will be needed to keep namespace registrations // in sync between the SW and each tab. It is not needed now because everything // is running in the same context - /* sbp('okTurtles.events/on', NAMESPACE_REGISTRATION, ({ name, value }) => { + sbp('okTurtles.events/on', NAMESPACE_REGISTRATION, ({ name, value }) => { const cache = sbp('state/vuex/state').namespaceLookups + const reverseCache = sbp('state/vuex/state').reverseNamespaceLookups Vue.set(cache, name, value) - }) */ + Vue.set(reverseCache, value, name) + }) // NOTE: setting 'EXPOSE_SBP' in production will make it easier for users to generate contract // actions that they shouldn't be generating, which can lead to bugs or trigger the automated @@ -154,6 +159,55 @@ async function startApp () { throw e }) + /* TODO: MOVE TO ANOTHER FILE */ + sbp('okTurtles.data/set', 'API_URL', self.location.origin) + const swRpc = (() => { + let controller = navigator.serviceWorker.controller + navigator.serviceWorker.addEventListener('controllerchange', (ev) => { + controller = navigator.serviceWorker.controller + }, false) + + return (...args) => { + return new Promise((resolve, reject) => { + const messageChannel = new MessageChannel() + messageChannel.port1.addEventListener('message', (event) => { + if (event.data && Array.isArray(event.data)) { + const r = deserializer(event.data[1]) + if (event.data[0] === true) { + resolve(r) + } else { + reject(r) + } + messageChannel.port1.close() + } + }, false) + messageChannel.port1.addEventListener('messageerror', (event) => { + reject(event.data) + messageChannel.port1.close() + }, false) + messageChannel.port1.start() + const { data, transferables } = serializer(args) + controller.postMessage({ + type: 'sbp', + port: messageChannel.port2, + data + }, [messageChannel.port2, ...transferables]) + }) + } + })() + + sbp('sbp/selectors/register', { + 'gi.actions/*': swRpc + }) + sbp('sbp/selectors/register', { + 'chelonia/*': swRpc + }) + sbp('sbp/selectors/register', { + 'sw-namespace/*': (...args) => { + return swRpc(args[0].slice(3), ...args.slice(1)) + } + }) + /* eslint-disable no-new */ new Vue({ router: router, @@ -298,7 +352,7 @@ async function startApp () { // happened (an example where things can happen this quickly is in the // tests). let oldIdentityContractID = null - setupChelonia().then(() => sbp('gi.db/settings/load', SETTING_CURRENT_USER)).then(async (identityContractID) => { + Promise.resolve().then(() => sbp('gi.db/settings/load', SETTING_CURRENT_USER)).then(async (identityContractID) => { oldIdentityContractID = identityContractID if (!identityContractID || this.ephemeral.finishedLogin === 'yes') return await sbp('gi.app/identity/login', { identityContractID }) diff --git a/frontend/setupChelonia.js b/frontend/setupChelonia.js index 6a74ff0557..1285b3d8bb 100644 --- a/frontend/setupChelonia.js +++ b/frontend/setupChelonia.js @@ -58,7 +58,7 @@ const setupChelonia = async (): Promise<*> => { }) // this is to ensure compatibility between frontend and test/backend.test.js - sbp('okTurtles.data/set', 'API_URL', window.location.origin) + sbp('okTurtles.data/set', 'API_URL', self.location.origin) // Used in 'chelonia/configure' hooks to emit an error notification. const errorNotification = (activity: string, error: Error, message: GIMessage) => { @@ -131,13 +131,14 @@ const setupChelonia = async (): Promise<*> => { exposedGlobals: { // note: needs to be written this way and not simply "Notification" // because that breaks on mobile where Notification is undefined - Notification: window.Notification + Notification: self.Notification } } }, hooks: { handleEventError: (e: Error, message: GIMessage) => { if (e.name === 'ChelErrorUnrecoverable') { + // TODO: Forward to app sbp('gi.ui/seriousErrorBanner', e) } if (sbp('okTurtles.data/get', 'sideEffectError') !== message.hash()) { diff --git a/shared/domains/chelonia/GIMessage.js b/shared/domains/chelonia/GIMessage.js index e6c7460862..e772b2bd9d 100644 --- a/shared/domains/chelonia/GIMessage.js +++ b/shared/domains/chelonia/GIMessage.js @@ -474,7 +474,7 @@ export class GIMessage { // $FlowFixMe[unsupported-syntax] static [serdesSerializeSymbol] (m: GIMessage) { - return [m.serialize(), m.direction(), m.decryptedValue(), m.innerSigningKey()] + return [m.serialize(), m.direction(), m.decryptedValue(), m.innerSigningKeyId()] } // $FlowFixMe[unsupported-syntax] diff --git a/shared/domains/chelonia/crypto.js b/shared/domains/chelonia/crypto.js index 9acb60cf2c..0ff0fb4a4b 100644 --- a/shared/domains/chelonia/crypto.js +++ b/shared/domains/chelonia/crypto.js @@ -21,6 +21,7 @@ export const EXTERNALKM32 = 'externalkm32' const bytesOrObjectToB64 = (ary: Uint8Array) => { if (!(ary instanceof Uint8Array)) { + console.error('@@@bytesOrObjectToB64', ary, ary.constructor, ary instanceof Uint8Array) throw Error('Unsupported type') } return bytesToB64(ary) diff --git a/shared/serdes/index.js b/shared/serdes/index.js index 64449872f2..99d27d0077 100644 --- a/shared/serdes/index.js +++ b/shared/serdes/index.js @@ -58,19 +58,25 @@ export const serializer = (data: any): any => { return rawResult(['_', 'Set', Array.from(value.entries())]) } // Error, Blob, File, etc. are supported by structuredClone but not by JSON - // We mark these as 'refs', so that the reviver can undo tris transformation + // We mark these as 'refs', so that the reviver can undo this transformation if (value instanceof Error || value instanceof Blob || value instanceof File) { const pos = verbatim.length verbatim[verbatim.length] = value return rawResult(['_', '_ref', pos]) } // Same for other types supported by structuredClone but not JSON - if (value instanceof MessagePort || value instanceof ReadableStream || value instanceof WritableStream || ArrayBuffer.isView(value) || value instanceof ArrayBuffer) { + if (value instanceof MessagePort || value instanceof ReadableStream || value instanceof WritableStream || value instanceof ArrayBuffer) { const pos = verbatim.length verbatim[verbatim.length] = value transferables.add(value) return rawResult(['_', '_ref', pos]) } + if (ArrayBuffer.isView(value)) { + const pos = verbatim.length + verbatim[verbatim.length] = value + transferables.add(value.buffer) + return rawResult(['_', '_ref', pos]) + } // Functions aren't supported neither by structuredClone nor JSON. However, // we can convert functions into a MessagePort, which is supported if (typeof value === 'function') {