diff --git a/packages/external-plugin-previewer/package.json b/packages/external-plugin-previewer/package.json new file mode 100644 index 000000000000..13dbd59afddc --- /dev/null +++ b/packages/external-plugin-previewer/package.json @@ -0,0 +1,17 @@ +{ + "name": "@dimensiondev/external-plugin-previewer", + "version": "0.0.0", + "private": true, + "scripts": { + "start": "dev -- snowpack dev" + }, + "dependencies": { + "@dimensiondev/maskbook-shared": "workspace:*", + "ef.js": "^0.13.6" + }, + "devDependencies": { + "snowpack": "^3.0.11" + }, + "main": "./dist/index.js", + "types": "./dist" +} diff --git a/packages/external-plugin-previewer/public/index.html b/packages/external-plugin-previewer/public/index.html new file mode 100644 index 000000000000..1a99104ee45c --- /dev/null +++ b/packages/external-plugin-previewer/public/index.html @@ -0,0 +1,12 @@ + + + + + + External plugin debug playground + + +
+ + + diff --git a/packages/external-plugin-previewer/snowpack.config.js b/packages/external-plugin-previewer/snowpack.config.js new file mode 100644 index 000000000000..0ee35b8b7403 --- /dev/null +++ b/packages/external-plugin-previewer/snowpack.config.js @@ -0,0 +1,14 @@ +// Snowpack Configuration File +// See all supported options: https://www.snowpack.dev/reference/configuration + +/** @type {import("snowpack").SnowpackUserConfig } */ +module.exports = { + mount: { + public: { url: '/' }, + src: { url: '/dist' }, + }, + plugins: [], + packageOptions: {}, + devOptions: { port: 28194 }, + buildOptions: {}, +} diff --git a/packages/external-plugin-previewer/src/Components/MaskCard.tsx b/packages/external-plugin-previewer/src/Components/MaskCard.tsx new file mode 100644 index 000000000000..b99641720857 --- /dev/null +++ b/packages/external-plugin-previewer/src/Components/MaskCard.tsx @@ -0,0 +1,49 @@ +import { Card, CardContent, Typography, CardActions, Button } from '@material-ui/core' +import { hostConfig } from '../host' +import type { Component } from './index' +import { useRef } from 'react' +export const MaskCard: Component = (props) => { + const ref = useRef(null) + return ( + + + + {String(props.caption)} + + + + + + + + + + + + + ) +} +MaskCard.displayName = 'mask-card' +export interface MaskCardProps { + caption: string + title: string + button: string + href: string +} +function getContext(node: Node | ShadowRoot | null): string | null { + if (!node) return null + if (node instanceof Element && node.hasAttribute('data-plugin')) { + return node.getAttribute('data-plugin') + } + if (node instanceof ShadowRoot) return getContext(node.host) + if (node.parentNode) return getContext(node.parentNode) + return null +} diff --git a/packages/external-plugin-previewer/src/Components/Translate.tsx b/packages/external-plugin-previewer/src/Components/Translate.tsx new file mode 100644 index 000000000000..067e0630b4f6 --- /dev/null +++ b/packages/external-plugin-previewer/src/Components/Translate.tsx @@ -0,0 +1,10 @@ +import type { Component } from '.' + +export const Translate: Component<{}> = () => { + return ( + + i18n: + + ) +} +Translate.displayName = 'i18n-translate' diff --git a/packages/external-plugin-previewer/src/Components/index.tsx b/packages/external-plugin-previewer/src/Components/index.tsx new file mode 100644 index 000000000000..26e3f3c900ff --- /dev/null +++ b/packages/external-plugin-previewer/src/Components/index.tsx @@ -0,0 +1,25 @@ +import { createElement } from 'react' + +export { MaskCard } from './MaskCard' +export { Translate } from './Translate' + +export interface Component

{ + (props: P, dispatchEvent: (event: Event) => void): React.ReactChild + displayName: string +} + +export const span = createNativeTagDelegate('span') +export const div = createNativeTagDelegate('div') +export const br = createNativeTagDelegate('br', { children: false }) +function createNativeTagDelegate( + tag: T, + accpetProps?: { [key in keyof HTMLElementTagNameMap[T]]?: boolean }, +) { + const C: Component<{}> = () => { + // TODO: implement acceptProps + if (accpetProps?.children === false) return createElement(tag) + return createElement(tag, {}, ) + } + C.displayName = tag + return C +} diff --git a/packages/external-plugin-previewer/src/DOMImpl.tsx b/packages/external-plugin-previewer/src/DOMImpl.tsx new file mode 100644 index 000000000000..96e4e5ba820b --- /dev/null +++ b/packages/external-plugin-previewer/src/DOMImpl.tsx @@ -0,0 +1,73 @@ +import { setDOMImpl } from 'ef.js' +import type {} from 'react/experimental' +import type {} from 'react-dom/experimental' +import { createReactRootShadowedPartial, ReactRootShadowed } from '@dimensiondev/maskbook-shared' +import * as Components from './Components' + +const createReactRootShadowed = createReactRootShadowedPartial({ + preventEventPropagationList: [], +}) +setDOMImpl({ + Node, + document: new Proxy(document, { + get(doc, key) { + if (key === 'createElement') return createElement + const val = (doc as any)[key] + if (typeof val === 'function') return val.bind(doc) + return val + }, + }), +}) + +function createElement(element: string, options: ElementCreationOptions) { + element = options.is || element + const _ = shouldRender(element) + const isValid = _ !== unknown + const [nativeTag, Component] = _ + const DOM = document.createElement(nativeTag) + DOM.setAttribute('data-kind', element) + + const shadow = DOM.attachShadow({ mode: 'open' }) + + const props: any = { __proto__: null } + isValid && render(Component, props, shadow) + + // No attributes allowed + DOM.setAttribute = () => {} + + // No need to hook event listeners + + // Hook property access + const proto = Object.getPrototypeOf(DOM) + Object.setPrototypeOf( + DOM, + new Proxy(proto, { + set(target, prop, value, receiver) { + // Forward them instead. + props[prop] = value + isValid && render(Component, props, shadow) + return true + }, + }), + ) + return DOM +} + +function render(f: Components.Component, props: any, shadow: ShadowRoot) { + const root: ReactRootShadowed = + (shadow as any).__root || ((shadow as any).__root = createReactRootShadowed(shadow, { tag: 'span' })) + root.render( f(props, (event) => void shadow.host.dispatchEvent(event))} />) +} +// Need use a JSX component to hold hooks +function HooksContainer(props: { f: () => React.ReactNode }) { + return <>{props.f()} +} + +const unknown = ['span', (() => null) as any as Components.Component] as const + +function shouldRender(element: string): readonly [string, Components.Component] { + for (const F of Object.values(Components)) { + if (F.displayName === element) return ['span', F] + } + return unknown +} diff --git a/packages/external-plugin-previewer/src/global.d.ts b/packages/external-plugin-previewer/src/global.d.ts new file mode 100644 index 000000000000..e5ee47d27a87 --- /dev/null +++ b/packages/external-plugin-previewer/src/global.d.ts @@ -0,0 +1,26 @@ +declare module 'ef.js' { + export interface DOMImpl { + Node: typeof Node + document: typeof document + } + export function setDOMImpl(impl: DOMImpl): void + export function create(template: string | TemplateStringsArray): typeof Component + + // Not exported + class Component> { + constructor(options?: ComponentConstructorOptions) + $mount(opt: MountOptions): void + $destroy(): void + $methods: Record + $data: T + $subscribe(key: keyof T, callback: Function): void + $unsubscribe(key: keyof T, callback: Function): void + } + export interface ComponentConstructorOptions { + $data: T + } + export interface MountOptions { + target: Node + } + export function t(template: TemplateStringsArray): typeof Component +} diff --git a/packages/external-plugin-previewer/src/host.ts b/packages/external-plugin-previewer/src/host.ts new file mode 100644 index 000000000000..b4ca0a789685 --- /dev/null +++ b/packages/external-plugin-previewer/src/host.ts @@ -0,0 +1,12 @@ +/** @internal */ +export const hostConfig: HostConfig = { + permissionAwareOpen(url: string) { + return url + }, +} +export interface HostConfig { + permissionAwareOpen(url: string): void +} +export function setHostConfig(host: HostConfig) { + hostConfig.permissionAwareOpen = host.permissionAwareOpen +} diff --git a/packages/external-plugin-previewer/src/index.tsx b/packages/external-plugin-previewer/src/index.tsx new file mode 100644 index 000000000000..05ced39cd16d --- /dev/null +++ b/packages/external-plugin-previewer/src/index.tsx @@ -0,0 +1,32 @@ +export { setHostConfig } from './host' +export type { HostConfig } from './host' +/// +import { useEffect, useState } from 'react' +import { create } from 'ef.js' +import './DOMImpl' +export function MaskExternalPluginPreviewRenderer({ pluginBase, payload, script, template, onError }: RenderData) { + const [dom, setDOM] = useState(null) + useEffect(() => { + if (!dom) return + dom.setAttribute('data-plugin', pluginBase) + // This is safe. ef template does not allow any form of dynamic code execute in the template. + try { + const RemoteContent = create(template) + const instance = new RemoteContent({ $data: { payload } }) + instance.$mount({ target: dom }) + return () => instance.$destroy() + } catch (e) { + onError?.(e) + } + return + }, [dom, onError, payload, template, pluginBase]) + return

setDOM(ref)} /> +} +export interface RenderData { + pluginBase: string + template: string + /** Currently not supported */ + script: string + payload: unknown + onError?(e: Error): void +} diff --git a/packages/external-plugin-previewer/src/playground.ts b/packages/external-plugin-previewer/src/playground.ts new file mode 100644 index 000000000000..e244d78d4522 --- /dev/null +++ b/packages/external-plugin-previewer/src/playground.ts @@ -0,0 +1,27 @@ +import React from 'react' +import { t } from 'ef.js' +import { setupPortalShadowRoot } from '@dimensiondev/maskbook-shared' +setupPortalShadowRoot({ mode: 'open' }, []) + +Object.assign(globalThis, { React }) + +const HelloWorld = t` +>mask-card + %caption = Caption! + %title = This is preview of id {{payload.id}} + %button = Details + >mask-card + %caption = Caption! + %title = This is preview of id {{payload.id}} + %button = Details +` + +const ins = new HelloWorld() +console.log('ins = ', ((globalThis as any).ins = ins)) +ins.$mount({ target: document.body }) +// will be set by Mask +ins.$data.payload = { + id: 1, +} + +export {} diff --git a/packages/external-plugin-previewer/tsconfig.json b/packages/external-plugin-previewer/tsconfig.json new file mode 100644 index 000000000000..5a5c54905e98 --- /dev/null +++ b/packages/external-plugin-previewer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "stripInternal": true + }, + "include": ["./src/**/*"], + "ts-node": { + "transpileOnly": true, + "compilerOptions": { "module": "CommonJS" } + } +} diff --git a/packages/maskbook/package.json b/packages/maskbook/package.json index 4880148f4d33..b1c8fd9ac3b9 100644 --- a/packages/maskbook/package.json +++ b/packages/maskbook/package.json @@ -10,8 +10,9 @@ "@dimensiondev/common-protocols": "1.6.0-20201027083702-d0ae6e2", "@dimensiondev/contracts": "workspace:*", "@dimensiondev/dashboard": "workspace:*", - "@dimensiondev/holoflows-kit": "0.8.0-20210317064617-6c4792c", "@dimensiondev/icons": "workspace:*", + "@dimensiondev/external-plugin-previewer": "workspace:*", + "@dimensiondev/holoflows-kit": "0.8.0-20210317064617-6c4792c", "@dimensiondev/kit": "0.0.0-20210221102734-0b4a937", "@dimensiondev/mask-plugin-infra": "workspace:*", "@dimensiondev/maskbook-shared": "workspace:*", diff --git a/packages/maskbook/src/content-script.ts b/packages/maskbook/src/content-script.ts index 0783486495bb..bf337b1956c2 100644 --- a/packages/maskbook/src/content-script.ts +++ b/packages/maskbook/src/content-script.ts @@ -1,6 +1,12 @@ import './extension/content-script/hmr' +import Services from './extension/service' import { status } from './setup.ui' status.then((loaded) => { loaded && import('./extension/content-script/tasks') }) + +// The scope should be the ./ of the web page +Services.ThirdPartyPlugin.isSDKEnabled(new URL('./', location.href).href).then((result) => { + result && import('./extension/external-sdk') +}) diff --git a/packages/maskbook/src/extension/background-script/ThirdPartyPlugin/index.ts b/packages/maskbook/src/extension/background-script/ThirdPartyPlugin/index.ts new file mode 100644 index 000000000000..5cf9473208bd --- /dev/null +++ b/packages/maskbook/src/extension/background-script/ThirdPartyPlugin/index.ts @@ -0,0 +1,70 @@ +import type { Manifest } from '../../../plugins/External/types' +import { constructThirdPartyRequestPermissionURL } from '../../popups/ThirdPartyRequestPermission/utils' +import { ThirdPartyPluginPermission } from './types' + +export async function fetchManifest(addr: string): Promise { + const response = await fetch(addr + 'mask-manifest.json') + const json = await response.text().then(JSONC) + // TODO: verify manifest + return JSON.parse(json) + + function JSONC(x: string) { + return x + .split('\n') + .filter((x) => !x.match(/^ +\/\//)) + .join('\n') + } +} +export async function openPluginPopup(url: string) { + new URL(url) // it must be a full qualified URL otherwise throws + const { id: windowID } = await browser.windows.create({ + type: 'popup', + width: 350, + height: 600, + url, + }) + return new Promise((resolve) => { + browser.windows.onRemoved.addListener(function listener(id) { + if (id !== windowID) return + browser.windows.onRemoved.removeListener(listener) + resolve() + }) + }) +} +export async function isSDKEnabled(baseURL: string) { + return hasPermission(baseURL, [ThirdPartyPluginPermission.SDKEnabled]) +} +export async function enableSDK(baseURL: string) { + return grantPermission(baseURL, [ThirdPartyPluginPermission.SDKEnabled]) +} +/** + * Check if the given URL has the permissions. + */ +export async function hasPermission(baseURL: string, permissions: ThirdPartyPluginPermission[]): Promise { + return permissions.every((p) => hasPermissionInternal(baseURL, p)) +} + +/** + * Request permission for the given URL. + * + */ +export async function requestPermission(baseURL: string, permissions: ThirdPartyPluginPermission[]): Promise { + if (await hasPermission(baseURL, permissions)) return true + await openPluginPopup(constructThirdPartyRequestPermissionURL(baseURL, permissions)) + return hasPermission(baseURL, permissions) +} + +/** + * DO NOT call this in the SDK. It should be called in the popups. + * + * Notice: In this demo implementation, all permissions are stored in the sessionStorage and will lost after the plugin refresh. + */ +export async function grantPermission(baseURL: string, permissions: ThirdPartyPluginPermission[]) { + for (const permission of permissions) + sessionStorage.setItem(`plugin:${ThirdPartyPluginPermission[permission]}:${baseURL}`, '1') +} + +/** @internal Do not export */ +function hasPermissionInternal(baseURL: string, permission: ThirdPartyPluginPermission) { + return !!sessionStorage.getItem(`plugin:${ThirdPartyPluginPermission[permission]}:${baseURL}`) +} diff --git a/packages/maskbook/src/extension/background-script/ThirdPartyPlugin/types.ts b/packages/maskbook/src/extension/background-script/ThirdPartyPlugin/types.ts new file mode 100644 index 000000000000..24eca7736bfe --- /dev/null +++ b/packages/maskbook/src/extension/background-script/ThirdPartyPlugin/types.ts @@ -0,0 +1,12 @@ +export enum ThirdPartyPluginPermission { + /** + * This is the internal permission that used to indicate if we should inject + * sdk into a content script. + * + * This permission will be automatically granted when user interact with the plugin in the SNS. + * + * This permission should be revoked once the popup has closed. + */ + SDKEnabled, + DEBUG_Profiles, +} diff --git a/packages/maskbook/src/extension/external-sdk/README.md b/packages/maskbook/src/extension/external-sdk/README.md new file mode 100644 index 000000000000..695b28bad96d --- /dev/null +++ b/packages/maskbook/src/extension/external-sdk/README.md @@ -0,0 +1 @@ +This entry is used to load Mask SDK into the webpage. It will be loaded on demand. diff --git a/packages/maskbook/src/extension/external-sdk/constant.ts b/packages/maskbook/src/extension/external-sdk/constant.ts new file mode 100644 index 000000000000..bad5f563dd79 --- /dev/null +++ b/packages/maskbook/src/extension/external-sdk/constant.ts @@ -0,0 +1,13 @@ +import type { ThirdPartyPopupContextIdentifier } from '../../plugins/External/popup-context' + +export const currentPopupContext = new URL(location.href).searchParams.get( + 'mask_context', +) as ThirdPartyPopupContextIdentifier | null + +export const currentBaseURL = new URL('./', location.href).toString() + +export enum SDKErrors { + M1_Lack_context_identifier = 'MaskErr/1: This page does not tied to any SNS context.', + M2_Context_disconnected = 'MaskErr/2: The SNS context associated with this page has gone.', + M3_Permission_denied = 'MaskErr/3: Permission not granted.', +} diff --git a/packages/maskbook/src/extension/external-sdk/hmr-sdk.ts b/packages/maskbook/src/extension/external-sdk/hmr-sdk.ts new file mode 100644 index 000000000000..2c038b2409de --- /dev/null +++ b/packages/maskbook/src/extension/external-sdk/hmr-sdk.ts @@ -0,0 +1,8 @@ +import * as SDK from './sdk' +const hmrSDK = { ...SDK } +export default hmrSDK +if (module.hot) { + module.hot.accept('./sdk', async () => { + Object.assign(hmrSDK, await import('./sdk')) + }) +} diff --git a/packages/maskbook/src/extension/external-sdk/index.ts b/packages/maskbook/src/extension/external-sdk/index.ts new file mode 100644 index 000000000000..6bf81ce4fa4b --- /dev/null +++ b/packages/maskbook/src/extension/external-sdk/index.ts @@ -0,0 +1,25 @@ +import './constant' +import { AsyncCall, JSONSerialization, EventBasedChannel } from 'async-call-rpc' + +console.log('SDK server started') +const channel: EventBasedChannel = { + on(listener) { + const l = (x: Event) => x instanceof CustomEvent && listener(x.detail) + document.addEventListener('mask-in', l) + return () => document.removeEventListener('mask-in', l) + }, + send(message) { + document.dispatchEvent(new CustomEvent('mask-out', { detail: message })) + }, +} + +AsyncCall( + import('./hmr-sdk').then((x) => x.default), + { + serializer: JSONSerialization(undefined), + channel, + log: false, + }, +) +document.dispatchEvent(new Event('mask-start')) +document.querySelector('html')?.setAttribute('data-mask-sdk-ready', 'true') diff --git a/packages/maskbook/src/extension/external-sdk/sdk.ts b/packages/maskbook/src/extension/external-sdk/sdk.ts new file mode 100644 index 000000000000..c020ca64a704 --- /dev/null +++ b/packages/maskbook/src/extension/external-sdk/sdk.ts @@ -0,0 +1,35 @@ +// !! Change existing signature of anything this file exports leads to a breaking change. +import { MaskMessage } from '../../utils' +import { ThirdPartyPluginPermission } from '../background-script/ThirdPartyPlugin/types' +import Services from '../service' +import { currentBaseURL, SDKErrors } from './constant' +import { __validateRemoteContext } from './sdk/context' + +/** Version of this SDK */ +export async function version() { + return 1 +} +export { __assertLocalContext, __validateRemoteContext } from './sdk/context' +export async function getProfile() { + const granted = await Services.ThirdPartyPlugin.requestPermission(new URL('./', location.href).toString(), [ + ThirdPartyPluginPermission.DEBUG_Profiles, + ]) + if (!granted) throw new Error(SDKErrors.M3_Permission_denied) + return (await Services.Identity.queryProfiles()).map((x) => x.identifier.userId) +} +export async function setPayload(payload: Record, options: { additionText: string }) { + const context = await __validateRemoteContext() + + const url = currentBaseURL.replace(/^https?:\/\//, '') + const namespacedPayload: Record = {} + for (const key in payload) { + // plugin:dimensiondev.github.io/Mask-Plugin-Example/@v1 + namespacedPayload[`plugin:${url}@${key}`] = payload[key] + } + + MaskMessage.events.thirdPartySetPayload.sendToContentScripts({ + payload: namespacedPayload, + context, + appendText: options.additionText, + }) +} diff --git a/packages/maskbook/src/extension/external-sdk/sdk/context.ts b/packages/maskbook/src/extension/external-sdk/sdk/context.ts new file mode 100644 index 000000000000..0cf41b46de20 --- /dev/null +++ b/packages/maskbook/src/extension/external-sdk/sdk/context.ts @@ -0,0 +1,35 @@ +import type { ThirdPartyPopupContextIdentifier } from '../../../plugins/External/popup-context' +import { MaskMessage } from '../../../utils' +import { currentPopupContext, SDKErrors } from '../constant' + +/** @internal */ +export async function __assertLocalContext() { + if (!currentPopupContext) throw new Error(SDKErrors.M1_Lack_context_identifier) +} + +/** @internal To make sure the remote context still alive */ +export function __validateRemoteContext() { + if (isContextDisconnected) return Promise.reject(new Error(SDKErrors.M2_Context_disconnected)) + + return new Promise((resolve, reject) => { + if (!currentPopupContext) throw onContextDisconnected() + const challenge = Math.random() + const f = MaskMessage.events.thirdPartyPong.on((i) => { + if (i !== challenge) return + resolve(currentPopupContext!) + f() + }) + MaskMessage.events.thirdPartyPing.sendToContentScripts({ + context: currentPopupContext, + challenge, + }) + setTimeout(() => reject(onContextDisconnected()), 2000) + }) +} + +let isContextDisconnected = false +function onContextDisconnected() { + isContextDisconnected = true + document.dispatchEvent(new Event('mask-sdk-disconnected')) + return new Error(SDKErrors.M2_Context_disconnected) +} diff --git a/packages/maskbook/src/extension/popups/MissingParameter/index.tsx b/packages/maskbook/src/extension/popups/MissingParameter/index.tsx new file mode 100644 index 000000000000..389e42db4b1c --- /dev/null +++ b/packages/maskbook/src/extension/popups/MissingParameter/index.tsx @@ -0,0 +1,12 @@ +import { Card, CardContent, Typography } from '@material-ui/core' + +export function MissingParameter(props: { message: string }) { + return ( + + + {props.message} + Please close this page. + + + ) +} diff --git a/packages/maskbook/src/extension/popups/PermissionAwareRedirect/index.tsx b/packages/maskbook/src/extension/popups/PermissionAwareRedirect/index.tsx new file mode 100644 index 000000000000..5ed6cb8d49d4 --- /dev/null +++ b/packages/maskbook/src/extension/popups/PermissionAwareRedirect/index.tsx @@ -0,0 +1,41 @@ +export { PermissionAwareRedirectUI } from './ui' + +import { useEffect } from 'react' +import { useLocation } from 'react-router' +import { useAsyncRetry } from 'react-use' +import Services from '../../service' +import { MissingParameter } from '../MissingParameter' +import { PermissionAwareRedirectUI } from './ui' +import { getHostPermissionFieldFromURL, isValidURL } from './utils' +export default function PermissionAwareRedirect() { + const url = new URLSearchParams(useLocation().search).get('url') + const context = new URLSearchParams(useLocation().search).get('context') + if (!url) return + if (!context) return + if (!isValidURL(url)) return + return +} +function Inner({ url, context }: { url: string; context: string }) { + const { value: hasPermission, retry } = useAsyncRetry(async () => { + if (!url) return false + return browser.permissions.contains({ origins: [getHostPermissionFieldFromURL(url)] }) + }, [url]) + useEffect(() => { + if (hasPermission) { + Services.ThirdPartyPlugin.enableSDK(new URL('./', url).href).then(() => { + const u = new URL(url) + u.searchParams.append('mask_context', context) + location.href = u.toString() + }) + } + }, [hasPermission, url]) + return ( + { + browser.permissions.request({ origins: [getHostPermissionFieldFromURL(url)] }).finally(retry) + }} + /> + ) +} diff --git a/packages/maskbook/src/extension/popups/PermissionAwareRedirect/ui.tsx b/packages/maskbook/src/extension/popups/PermissionAwareRedirect/ui.tsx new file mode 100644 index 000000000000..e19105325d6c --- /dev/null +++ b/packages/maskbook/src/extension/popups/PermissionAwareRedirect/ui.tsx @@ -0,0 +1,29 @@ +import { Typography, Card, CardContent, Button, CardActions } from '@material-ui/core' + +export function PermissionAwareRedirectUI(props: PermissionAwareRedirectProps) { + if (props.granted) { + return ( + <> + Redirect to {props.url}. +
+ If your browser does not redirect, please click here. + + ) + } + return ( + + + Mask need permission for: + {props.url} + + + + + + ) +} +export interface PermissionAwareRedirectProps { + url: string + onRequest(): void + granted: boolean +} diff --git a/packages/maskbook/src/extension/popups/PermissionAwareRedirect/utils.ts b/packages/maskbook/src/extension/popups/PermissionAwareRedirect/utils.ts new file mode 100644 index 000000000000..f4e9c40874d5 --- /dev/null +++ b/packages/maskbook/src/extension/popups/PermissionAwareRedirect/utils.ts @@ -0,0 +1,12 @@ +export function getHostPermissionFieldFromURL(url: string) { + const u = new URL(url) + return `*://${u.hostname}/*` +} +export function isValidURL(url: string): boolean { + try { + const u = new URL(url) + return u.protocol.startsWith('http') + } catch { + return false + } +} diff --git a/packages/maskbook/src/extension/popups/RequestPermission/index.tsx b/packages/maskbook/src/extension/popups/RequestPermission/index.tsx index 805a43e0d22d..78a67a98c5a8 100644 --- a/packages/maskbook/src/extension/popups/RequestPermission/index.tsx +++ b/packages/maskbook/src/extension/popups/RequestPermission/index.tsx @@ -18,7 +18,7 @@ function isAcceptablePermission(x: string): x is browser.permissions.Permission return (acceptable as string[]).includes(x) } -export function RequestPermissionPage() { +export default function RequestPermissionPage() { const param = useLocation() const _ = new URLSearchParams(param.search) const origins = _.getAll('origins') diff --git a/packages/maskbook/src/extension/popups/ThirdPartyRequestPermission/ThirdPartyRequestPermission.tsx b/packages/maskbook/src/extension/popups/ThirdPartyRequestPermission/ThirdPartyRequestPermission.tsx new file mode 100644 index 000000000000..3caa8bf48423 --- /dev/null +++ b/packages/maskbook/src/extension/popups/ThirdPartyRequestPermission/ThirdPartyRequestPermission.tsx @@ -0,0 +1,22 @@ +import { ThirdPartyPluginPermission } from '../../background-script/ThirdPartyPlugin/types' + +export interface ThirdPartyRequestPermissionProps { + pluginURL: string + pluginName: string + permissions: ThirdPartyPluginPermission[] + onGrant(permissions: ThirdPartyPluginPermission[]): void +} +export function ThirdPartyRequestPermission(props: ThirdPartyRequestPermissionProps) { + return ( +
+ The plugin "{props.pluginName}" (hosted on {props.pluginURL}) is going to request the following permissions: +
    + {props.permissions.map((x) => ( +
  • {ThirdPartyPluginPermission[x]}
  • + ))} +
+ + +
+ ) +} diff --git a/packages/maskbook/src/extension/popups/ThirdPartyRequestPermission/index.tsx b/packages/maskbook/src/extension/popups/ThirdPartyRequestPermission/index.tsx new file mode 100644 index 000000000000..b35a8de1a0b6 --- /dev/null +++ b/packages/maskbook/src/extension/popups/ThirdPartyRequestPermission/index.tsx @@ -0,0 +1,24 @@ +import { useLocation } from 'react-router-dom' +import { useAsync } from 'react-use' +import Services from '../../service' +import { ThirdPartyRequestPermission } from './ThirdPartyRequestPermission' + +export default function () { + const param = useLocation() + const _ = new URLSearchParams(param.search) + const permission = _.getAll('permission') + const plugin = _.get('plugin') + const { value } = useAsync(() => Services.ThirdPartyPlugin.fetchManifest(plugin!), [plugin]) + if (!plugin) return null + if (!value) return null + return ( + parseInt(x, 10))} + onGrant={(granted) => { + Services.ThirdPartyPlugin.grantPermission(plugin, granted).then(() => window.close()) + }} + /> + ) +} diff --git a/packages/maskbook/src/extension/popups/ThirdPartyRequestPermission/utils.ts b/packages/maskbook/src/extension/popups/ThirdPartyRequestPermission/utils.ts new file mode 100644 index 000000000000..43fa6e1afc28 --- /dev/null +++ b/packages/maskbook/src/extension/popups/ThirdPartyRequestPermission/utils.ts @@ -0,0 +1,12 @@ +import { DialogRoutes, getRouteURLWithNoParam } from '..' +import type { ThirdPartyPluginPermission } from '../../background-script/ThirdPartyPlugin/types' + +export function constructThirdPartyRequestPermissionURL( + pluginManifestURL: string, + permissions: ThirdPartyPluginPermission[], +) { + const params = new URLSearchParams() + params.set('plugin', pluginManifestURL) + for (const x of permissions) params.append('permission', String(x)) + return getRouteURLWithNoParam(DialogRoutes.ThirdPartyRequestPermission) + '?' + params.toString() +} diff --git a/packages/maskbook/src/extension/popups/index.tsx b/packages/maskbook/src/extension/popups/index.tsx index 7c16951d8b3a..8a0596769d3e 100644 --- a/packages/maskbook/src/extension/popups/index.tsx +++ b/packages/maskbook/src/extension/popups/index.tsx @@ -1,8 +1,18 @@ +import type { ThirdPartyPopupContextIdentifier } from '../../plugins/External/popup-context' + export enum DialogRoutes { + PermissionAwareRedirect = '/redirect', RequestPermission = '/request-permission', + ThirdPartyRequestPermission = '/3rd-request-permission', } export function getRouteURLWithNoParam(kind: DialogRoutes) { return browser.runtime.getURL(`/popups.html#${kind}`) } +export function PermissionAwareRedirectOf(url: string, context: ThirdPartyPopupContextIdentifier) { + return ( + getRouteURLWithNoParam(DialogRoutes.PermissionAwareRedirect) + + `?url=${encodeURIComponent(url)}&context=${context}` + ) +} export { constructRequestPermissionURL } from './RequestPermission/utils' diff --git a/packages/maskbook/src/extension/popups/render.tsx b/packages/maskbook/src/extension/popups/render.tsx index ad1b2907f8b6..1bafa0661c0d 100644 --- a/packages/maskbook/src/extension/popups/render.tsx +++ b/packages/maskbook/src/extension/popups/render.tsx @@ -1,26 +1,34 @@ /// /// -import { Suspense } from 'react' +import { lazy, Suspense } from 'react' import { Route, Switch } from 'react-router' import { HashRouter } from 'react-router-dom' import ReactDOM from 'react-dom' import { MaskUIRoot } from '../../UIRoot' import { DialogRoutes } from '.' -import { RequestPermissionPage } from './RequestPermission' const root = document.createElement('div') document.body.insertBefore(root, document.body.children[0] || null) ReactDOM.createRoot(root).render() +const RequestPermissionPage = lazy(() => import('./RequestPermission')) +const PermissionAwareRedirect = lazy(() => import('./PermissionAwareRedirect')) +const ThirdPartyRequestPermission = lazy(() => import('./ThirdPartyRequestPermission')) function Dialogs() { return MaskUIRoot( - + + + + + + + , diff --git a/packages/maskbook/src/extension/service.ts b/packages/maskbook/src/extension/service.ts index 768b888a5f04..857177fdef0c 100644 --- a/packages/maskbook/src/extension/service.ts +++ b/packages/maskbook/src/extension/service.ts @@ -34,6 +34,7 @@ export const Services = { Ethereum: add(() => import('./background-script/EthereumService'), 'Ethereum'), SocialNetwork: add(() => import('./background-script/SocialNetworkService'), 'SocialNetwork'), Settings: add(() => import('./background-script/SettingsService'), 'Settings'), + ThirdPartyPlugin: add(() => import('./background-script/ThirdPartyPlugin'), 'ThirdPartyPlugin'), } export default Services export const ServicesWithProgress = add(() => import('./service-generator'), 'ServicesWithProgress', true) @@ -50,6 +51,7 @@ if (module.hot && isEnvironment(Environment.ManifestBackground)) { './background-script/ProviderService', './background-script/EthereumService', './background-script/SettingsService', + './background-script/ThirdPartyPlugin', './background-script/SocialNetworkService', './service-generator', ], diff --git a/packages/maskbook/src/plugin-infra/register.ts b/packages/maskbook/src/plugin-infra/register.ts index b2f33b6b374e..3c4e47d2325e 100644 --- a/packages/maskbook/src/plugin-infra/register.ts +++ b/packages/maskbook/src/plugin-infra/register.ts @@ -1 +1,2 @@ import '@dimensiondev/plugin-example' +import '../plugins/External' diff --git a/packages/maskbook/src/plugins/External/Dashboard/index.tsx b/packages/maskbook/src/plugins/External/Dashboard/index.tsx new file mode 100644 index 000000000000..12ba2a34c876 --- /dev/null +++ b/packages/maskbook/src/plugins/External/Dashboard/index.tsx @@ -0,0 +1,9 @@ +import type { Plugin } from '@dimensiondev/mask-plugin-infra' +import { base } from '../base' + +const dashboard: Plugin.Dashboard.Definition = { + ...base, + init(signal) {}, +} + +export default dashboard diff --git a/packages/maskbook/src/plugins/External/README.md b/packages/maskbook/src/plugins/External/README.md new file mode 100644 index 000000000000..4014bf9da9a9 --- /dev/null +++ b/packages/maskbook/src/plugins/External/README.md @@ -0,0 +1,17 @@ +# External plugins + +## Plugin management (TBD) + +Priority (highest to lowest): + +- Precise (`example.com/plugin`) +- Publisher (Must be code signed) (`publisher=HASH_OF_PUBLIC_KEY`) +- Domain based (`*.example.com/*`) +- Fallback/default settings + +### Settings can be tweaked (TBD) + +- Permission (only applicable for `Precise`, must grant case-by-case) +- Auto load unknown plugins (Not applicable for `Publisher`) +- Auto update plugins periodically +- Auto update everytime I use it (might cause too many web requests) diff --git a/packages/maskbook/src/plugins/External/SNSAdaptor/index.tsx b/packages/maskbook/src/plugins/External/SNSAdaptor/index.tsx new file mode 100644 index 000000000000..36d34e11b196 --- /dev/null +++ b/packages/maskbook/src/plugins/External/SNSAdaptor/index.tsx @@ -0,0 +1,44 @@ +import type { Plugin } from '@dimensiondev/mask-plugin-infra' +import { Suspense } from 'react' +import { ExternalPluginLoader } from '../components/Container' +import type { ExternalPluginLoadDetails } from '../types' +import { base } from '../base' +import { ThirdPartyPluginCompositionEntry } from '../components/CompositionEntry' + +const sns: Plugin.SNSAdaptor.Definition = { + ...base, + init(signal) {}, + DecryptedInspector: function Comp(props) { + const tm = props.message + if (!tm.meta) return null + let JSX: ExternalPluginLoadDetails[] = [] + for (const [key, meta] of tm.meta) { + if (!key.startsWith('plugin:')) continue + const [url, metaKey] = parse(key) + JSX.push({ meta, metaKey, url }) + } + // Loader itself has some async work and no need to display loading fallback + return ( + + + + ) + }, + CompositionDialogEntry: { label: '🧩 Third party plugins', dialog: ThirdPartyPluginCompositionEntry }, + CompositionDialogMetadataBadgeRender: (key, val) => + key.startsWith('plugin:') + ? { + text: `A 3rd party plugin (${key})`, + tooltip: `It's inner content: ${JSON.stringify(val)}`, + } + : null, +} + +export default sns +// plugin:dimensiondev.github.io/Mask-Plugin-Example/@v1 +function parse(x: string) { + let [address, ...key] = x.slice('plugin:'.length).split('@') + if (!address.endsWith('/')) address += '/' + const isLocalhost = new URL('https://' + address).hostname + return [(isLocalhost ? 'http://' : 'https://') + address, key.join('@')] +} diff --git a/packages/maskbook/src/plugins/External/Worker/index.ts b/packages/maskbook/src/plugins/External/Worker/index.ts new file mode 100644 index 000000000000..c982cf1e4865 --- /dev/null +++ b/packages/maskbook/src/plugins/External/Worker/index.ts @@ -0,0 +1,8 @@ +import type { Plugin } from '@dimensiondev/mask-plugin-infra' +import { base } from '../base' + +const worker: Plugin.Worker.Definition = { + ...base, + init(signal) {}, +} +export default worker diff --git a/packages/maskbook/src/plugins/External/base.ts b/packages/maskbook/src/plugins/External/base.ts new file mode 100644 index 000000000000..b7a1db986701 --- /dev/null +++ b/packages/maskbook/src/plugins/External/base.ts @@ -0,0 +1,14 @@ +import type { Plugin } from '@dimensiondev/mask-plugin-infra' + +export const base: Plugin.Shared.Definition = { + ID: 'io.maskbook.external', + icon: '🧩', + name: { fallback: 'Mask External Plugin Loader' }, + description: { fallback: 'Able to load external plugins.' }, + publisher: { name: { fallback: 'Mask Network' }, link: 'https://mask.io/' }, + enableRequirement: { + architecture: { app: true, web: true }, + networks: { type: 'opt-out', networks: {} }, + target: 'insider', + }, +} diff --git a/packages/maskbook/src/plugins/External/components/CompositionEntry.tsx b/packages/maskbook/src/plugins/External/components/CompositionEntry.tsx new file mode 100644 index 000000000000..17d905537a76 --- /dev/null +++ b/packages/maskbook/src/plugins/External/components/CompositionEntry.tsx @@ -0,0 +1,19 @@ +import type { Plugin } from '@dimensiondev/mask-plugin-infra/src' +import { usePortalShadowRoot } from '@dimensiondev/maskbook-shared' +import { MaskDialog } from '@dimensiondev/maskbook-theme' +import { DialogContent } from '@material-ui/core' +import { PluginLoader } from './PluginLoader' + +export function ThirdPartyPluginCompositionEntry(props: Plugin.SNSAdaptor.CompositionDialogEntry_DialogProps) { + return usePortalShadowRoot((container) => ( + + + + + + )) +} diff --git a/packages/maskbook/src/plugins/External/components/Container.tsx b/packages/maskbook/src/plugins/External/components/Container.tsx new file mode 100644 index 000000000000..31e474e47b72 --- /dev/null +++ b/packages/maskbook/src/plugins/External/components/Container.tsx @@ -0,0 +1,43 @@ +import { SnackbarContent } from '@material-ui/core' +import { useAsyncRetry } from 'react-use' +import { Suspense, useRef } from 'react' +import type { ExternalPluginLoadDetails } from '../types' +import { UnknownPluginLoadRequestUI } from './UnknownPluginLoadRequest' +import { ExternalPluginRenderer } from './ExternalPluginRenderer' +export interface ExternalPluginContainerProps { + plugins: ExternalPluginLoadDetails[] +} +export function ExternalPluginLoader(props: ExternalPluginContainerProps) { + // TODO: this section should use suspense and it will more nature + const { loading, value, retry } = useAsyncRetry(() => filterPlugin(props.plugins), [props.plugins.join('@')]) + const ref = useRef(value) + if (!ref.current && loading) return null + ref.current = value + if (!ref.current) return null + // + const { known, unknown } = ref.current + return ( + <> + { + list.forEach((x) => allowed.add(x.url)) + retry() + }} + /> + {known.map((x) => ( + }> + + + ))} + + ) +} +const allowed = new Set() +/** This function should query which plugin can be loaded directly. */ +async function filterPlugin(plugins: ExternalPluginLoadDetails[]) { + return { + unknown: plugins.filter((x) => !allowed.has(x.url)), + known: plugins.filter((x) => allowed.has(x.url)), + } +} diff --git a/packages/maskbook/src/plugins/External/components/ExternalPluginRenderer.tsx b/packages/maskbook/src/plugins/External/components/ExternalPluginRenderer.tsx new file mode 100644 index 000000000000..9bfc2959449e --- /dev/null +++ b/packages/maskbook/src/plugins/External/components/ExternalPluginRenderer.tsx @@ -0,0 +1,55 @@ +import type { ExternalPluginLoadDetails } from '../types' +import { Card, CardHeader, CardContent, Typography, Link, Button } from '@material-ui/core' +import Services from '../../../extension/service' +import { MaskExternalPluginPreviewRenderer, setHostConfig } from '@dimensiondev/external-plugin-previewer' +import { PermissionAwareRedirectOf } from '../../../extension/popups' +import { createThirdPartyPopupContext } from '../popup-context' +import { useExternalPluginManifest, useExternalPluginTemplate } from '../loader' + +setHostConfig({ + permissionAwareOpen(url) { + Services.ThirdPartyPlugin.openPluginPopup(PermissionAwareRedirectOf(url, createThirdPartyPopupContext())) + }, +}) + +export function ExternalPluginRenderer(props: ExternalPluginLoadDetails) { + const manifest = useExternalPluginManifest(props.url) + const template = useExternalPluginTemplate(props.url, manifest.value, props.metaKey) + const retry = ( + + {url ? : null} + + ) +} + +function Loader(props: { url: string }) { + const load = useExternalPluginManifest(props.url) + if (load.loading) return + if (load.error || !load.value) return + const contribution = load.value.contribution?.composition + return ( + + + External plugin: {load.value.name} + Description: {load.value.description} + Publisher: {load.value.publisher} + + {contribution ? ( + + ) : null} + + + ) +} diff --git a/packages/maskbook/src/plugins/External/components/UnknownPluginLoadRequest.tsx b/packages/maskbook/src/plugins/External/components/UnknownPluginLoadRequest.tsx new file mode 100644 index 000000000000..c96d05eefd22 --- /dev/null +++ b/packages/maskbook/src/plugins/External/components/UnknownPluginLoadRequest.tsx @@ -0,0 +1,63 @@ +import { + SnackbarContent, + Button, + List, + Checkbox, + ListSubheader, + ListItem, + ListItemText, + ListItemIcon, + Card, + CardContent, + CardActions, +} from '@material-ui/core' +import { useMap } from 'react-use' +import type { ExternalPluginLoadDetails } from '../types' + +export function UnknownPluginLoadRequestUI({ plugins, onConfirm }: UnknownPluginLoadRequestProps) { + const [_selected, { get, set }] = useMap({} as Record) + + const confirmAll = () => onConfirm(plugins) + const selected = plugins.filter((x) => _selected[x.url]) + const confirmSelected = () => onConfirm(selected) + + if (plugins.length === 0) return null + if (plugins.length === 1) + return ( + Load} + /> + ) + return ( + + + New unknown Mask plugins found. Do you want to load them? + }> + {plugins.map((x) => ( + set(x.url, !get(x.url))} key={x.url}> + + + + + + ))} + + + + + + + + ) +} + +export interface UnknownPluginLoadRequestProps { + plugins: ExternalPluginLoadDetails[] + onConfirm(list: ExternalPluginLoadDetails[]): void +} diff --git a/packages/maskbook/src/plugins/External/index.ts b/packages/maskbook/src/plugins/External/index.ts new file mode 100644 index 000000000000..6448de6b8ea4 --- /dev/null +++ b/packages/maskbook/src/plugins/External/index.ts @@ -0,0 +1,11 @@ +import { registerPlugin } from '@dimensiondev/mask-plugin-infra' +import { base } from './base' + +registerPlugin({ + ...base, + SNSAdaptor: { + load: () => import('./SNSAdaptor'), + hotModuleReload: (hot) => + module.hot && module.hot.accept('./SNSAdaptor/index', () => hot(import('./SNSAdaptor'))), + }, +}) diff --git a/packages/maskbook/src/plugins/External/loader/index.ts b/packages/maskbook/src/plugins/External/loader/index.ts new file mode 100644 index 000000000000..31a43fc58cb0 --- /dev/null +++ b/packages/maskbook/src/plugins/External/loader/index.ts @@ -0,0 +1,58 @@ +import { useAsyncRetry } from 'react-use' +import Services from '../../../extension/service' +import type { Manifest } from '../types' + +// TODO: support suspense +export function useExternalPluginManifest(url: string) { + return useAsyncRetry(() => Services.ThirdPartyPlugin.fetchManifest(url), [url]) +} + +export function useExternalPluginTemplate(url: string, manifest: Manifest | undefined, metaKey: string) { + const target = manifest?.metadata?.[metaKey] + // TODO: the final URL must inside/same directory of the manifest + const u = target ? new URL(target.preview, url).toString() : null + return useAsyncRetry(() => fetchTemplate(u), [u]) +} + +// TODO: support suspense, cache +async function fetchTemplate(url: string | null) { + if (!url) return + const blob = await Services.Helper.fetch(url) + const text = await blob.text() + const parser = new DOMParser() + const dom = parser.parseFromString(text, 'text/html').querySelector('template')?.innerHTML + if (!dom) return null + const template = parser.parseFromString(dom, 'text/html').querySelector('body')?.childNodes + if (!template) return null + console.log(htmlToTemplate(template)) + return htmlToTemplate(template) +} + +const indentLevel = 2 +function htmlToTemplate(top: NodeListOf) { + function* text(indent: number, text: Text) { + for (const line of text.textContent?.split('\n') || '') { + yield `${getIndent(indent)}. ${line}` + } + } + function* html(indent: number, node: HTMLElement): Generator { + yield `${getIndent(indent)}>${node.tagName.toLowerCase()}` + for (const attr of node.attributes) { + yield `${getIndent(indent + indentLevel)}%${attr.name} = ${attr.value}` + } + yield* convertList(indent + indentLevel, node.childNodes) + } + function* convertList(indent: number, nodes: NodeListOf): Generator { + for (const node of nodes) { + if (isText(node)) yield* text(indent, node) + else if (node instanceof HTMLElement) yield* html(indent, node) + } + } + function getIndent(x: number) { + return ' '.repeat(x) + } + function isText(node: ChildNode): node is Text { + return node.nodeType === document.TEXT_NODE + } + return [...convertList(0, top)].join('\n') +} diff --git a/packages/maskbook/src/plugins/External/popup-context.ts b/packages/maskbook/src/plugins/External/popup-context.ts new file mode 100644 index 000000000000..7685df43934b --- /dev/null +++ b/packages/maskbook/src/plugins/External/popup-context.ts @@ -0,0 +1,19 @@ +import './reactions' +const bindingContext = new Set() +export type ThirdPartyPopupContextIdentifier = string & { __brand__: 'context' } +/** + * Generate a new random Third Party popup Context identifier. + * + * Most of the API calls in the Third Party popup need this identifier, + * otherwise we don't know where should we connect the context to. + * + * The context will be invalidated if this page is lost. + */ +export function createThirdPartyPopupContext(): ThirdPartyPopupContextIdentifier { + const id = Math.random().toString(16).slice(2) as ThirdPartyPopupContextIdentifier + bindingContext.add(id) + return id +} +export function isLocalContext(x: string): x is ThirdPartyPopupContextIdentifier { + return bindingContext.has(x) +} diff --git a/packages/maskbook/src/plugins/External/reactions.ts b/packages/maskbook/src/plugins/External/reactions.ts new file mode 100644 index 000000000000..c98885e0edd1 --- /dev/null +++ b/packages/maskbook/src/plugins/External/reactions.ts @@ -0,0 +1,27 @@ +import { makeTypedMessageText } from '../../protocols/typed-message' +import { editActivatedPostMetadata } from '../../protocols/typed-message/global-state' +import { MaskMessage, startEffect } from '../../utils' +import { isLocalContext } from './popup-context' + +startEffect(module.hot, () => + MaskMessage.events.thirdPartyPing.on((data) => { + if (!isLocalContext(data.context)) return + MaskMessage.events.thirdPartyPong.sendToContentScripts(data.challenge) + }), +) + +startEffect(module.hot, () => + MaskMessage.events.thirdPartySetPayload.on((data) => { + if (!isLocalContext(data.context)) return + editActivatedPostMetadata((meta) => { + for (const [key, value] of Object.entries(data.payload)) { + meta.set(key, value) + } + }) + MaskMessage.events.compositionUpdated.sendToLocal({ + open: true, + reason: 'popup', + content: makeTypedMessageText(data.appendText), + }) + }), +) diff --git a/packages/maskbook/src/plugins/External/types.tsx b/packages/maskbook/src/plugins/External/types.tsx new file mode 100644 index 000000000000..bffeebfd4850 --- /dev/null +++ b/packages/maskbook/src/plugins/External/types.tsx @@ -0,0 +1,23 @@ +export interface ExternalPluginLoadDetails { + /** Where is the plugin hosts */ + url: string + /** Stripped meta key that invokes this plugin */ + metaKey: string + /** The metadata content */ + meta: unknown +} +export interface Manifest { + manifest_version: 0 + name: string + description?: string + publisher: string + metadata?: Record + contribution?: Manifest_Contribution +} +export interface Manifest_Contribution { + composition?: Manifest_Contribution_Composition +} +export interface Manifest_Contribution_Composition { + icon?: string + href: string +} diff --git a/packages/maskbook/src/utils/messages.ts b/packages/maskbook/src/utils/messages.ts index a415b001ba45..d4d417eaf69d 100644 --- a/packages/maskbook/src/utils/messages.ts +++ b/packages/maskbook/src/utils/messages.ts @@ -2,6 +2,7 @@ import { WebExtensionMessage } from '@dimensiondev/holoflows-kit' import Serialization from './type-transform/Serialization' import type { ProfileIdentifier, GroupIdentifier, PersonaIdentifier } from '../database/type' import type { TypedMessage } from '../protocols/typed-message' +import type { ThirdPartyPopupContextIdentifier } from '../plugins/External/popup-context' export interface UpdateEvent { readonly reason: 'update' | 'delete' | 'new' @@ -49,6 +50,14 @@ export interface MaskMessages { before: PersonaIdentifier | undefined after: PersonaIdentifier | undefined }[] + // When a SNS page get this event, if it know this context, it should response the challenge with pong. + thirdPartyPing: { context: ThirdPartyPopupContextIdentifier; challenge: number } + thirdPartyPong: number + thirdPartySetPayload: { + payload: Record + appendText: string + context: ThirdPartyPopupContextIdentifier + } } export const MaskMessage = new WebExtensionMessage({ domain: 'mask' }) Object.assign(globalThis, { MaskMessage }) diff --git a/packages/maskbook/tsconfig.json b/packages/maskbook/tsconfig.json index f1ec8d371562..74198b63745c 100644 --- a/packages/maskbook/tsconfig.json +++ b/packages/maskbook/tsconfig.json @@ -13,7 +13,8 @@ { "path": "../web3-shared" }, { "path": "../theme/" }, { "path": "../icons/" }, - { "path": "../plugin-infra" } + { "path": "../plugin-infra" }, + { "path": "../external-plugin-previewer" } ], "ts-node": { "transpileOnly": true, "compilerOptions": { "module": "CommonJS" } } } diff --git a/packages/maskbook/webpack.config.ts b/packages/maskbook/webpack.config.ts index dd079175530c..d531ad342d02 100644 --- a/packages/maskbook/webpack.config.ts +++ b/packages/maskbook/webpack.config.ts @@ -101,6 +101,9 @@ function config(opts: { '@dimensiondev/icons': require.resolve('../icons/index.ts'), '@dimensiondev/mask-plugin-infra': require.resolve('../plugin-infra/src/index.ts'), '@dimensiondev/plugin-example': require.resolve('../plugins/example/src/index.ts'), + '@dimensiondev/external-plugin-previewer': require.resolve( + '../external-plugin-previewer/src/index.tsx', + ), '@dimensiondev/web3-shared': require.resolve('../web3-shared/src/index.ts'), }, // Polyfill those Node built-ins diff --git a/packages/shared/src/ShadowRoot/createReactRootShadowed.tsx b/packages/shared/src/ShadowRoot/createReactRootShadowed.tsx index a905d2797a37..76d3049436d5 100644 --- a/packages/shared/src/ShadowRoot/createReactRootShadowed.tsx +++ b/packages/shared/src/ShadowRoot/createReactRootShadowed.tsx @@ -4,6 +4,8 @@ import type {} from 'react-dom/experimental' import { ShadowRootStyleProvider } from './ShadowRootStyleProvider' export interface RenderInShadowRootConfig { + /** Root tag. @default "main" */ + tag?: string /** Allow to render multiple React root into a same ShadowRoot */ key?: string /** The AbortSignal to stop the render */ @@ -62,8 +64,9 @@ function mount( instanceConfig: RenderInShadowRootConfig, globalConfig: CreateRenderInShadowRootConfig, ): ReactRootShadowed { + const tag = instanceConfig.tag || 'main' const key = instanceConfig.key || 'main' - if (shadow.querySelector(`main.${key}`)) { + if (shadow.querySelector(`${tag}.${key}`)) { console.error('Tried to create root in', shadow, 'with key', key, ' which is already used. Skip rendering.') return { destory: () => {}, @@ -74,7 +77,7 @@ function mount( const wrap = globalConfig.wrapJSX jsx = getJSX(jsx) - const container = shadow.appendChild(document.createElement('main')) + const container = shadow.appendChild(document.createElement(tag)) container.className = key let undoActions: Function[] = [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a6b341eed1a..747b01dd8997 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,17 @@ importers: babel-loader: 8.2.2_@babel+core@7.14.3 snowpack: 3.5.1 + packages/external-plugin-previewer: + specifiers: + '@dimensiondev/maskbook-shared': workspace:* + ef.js: ^0.13.6 + snowpack: ^3.0.11 + dependencies: + '@dimensiondev/maskbook-shared': link:../shared + ef.js: 0.13.7 + devDependencies: + snowpack: 3.5.1 + packages/icons: specifiers: '@dimensiondev/maskbook-theme': workspace:* @@ -213,6 +224,7 @@ importers: '@dimensiondev/common-protocols': 1.6.0-20201027083702-d0ae6e2 '@dimensiondev/contracts': workspace:* '@dimensiondev/dashboard': workspace:* + '@dimensiondev/external-plugin-previewer': workspace:* '@dimensiondev/holoflows-kit': 0.8.0-20210317064617-6c4792c '@dimensiondev/icons': workspace:* '@dimensiondev/kit': 0.0.0-20210221102734-0b4a937 @@ -375,6 +387,7 @@ importers: '@dimensiondev/common-protocols': 1.6.0-20201027083702-d0ae6e2 '@dimensiondev/contracts': link:../contracts '@dimensiondev/dashboard': link:../dashboard + '@dimensiondev/external-plugin-previewer': link:../external-plugin-previewer '@dimensiondev/holoflows-kit': 0.8.0-20210317064617-6c4792c_webextension-polyfill@0.8.0 '@dimensiondev/icons': link:../icons '@dimensiondev/kit': 0.0.0-20210221102734-0b4a937 @@ -11106,6 +11119,21 @@ packages: /ee-first/1.1.1: resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} + /ef-core/0.13.7: + resolution: {integrity: sha512-peBjbdcCapnWvMV/PCz+T58UzfRhCfc+fqqeoOsHs/X0oTgR18QDsmf7EFGwerL8n29Kuzb9OHLNsZVFaA7UWw==} + dev: false + + /ef.js/0.13.7: + resolution: {integrity: sha512-oxZU47Rx1Xz+YSwSorAwLHsGbspXgV47imO84OtZo4IJLFRb28ULpYjgtl/IIL0XA5fhsUk72vLGcyQNkVzRCA==} + dependencies: + ef-core: 0.13.7 + eft-parser: 0.13.5 + dev: false + + /eft-parser/0.13.5: + resolution: {integrity: sha512-Hj+GY0mf2NNBcgg0v6DsV0/EYI5xDLUICZ5BSLVC+8uFEzoWpWw2VR6e3eWKgGjL2YEybUdg89UXDEHaBmi03Q==} + dev: false + /electron-to-chromium/1.3.703: resolution: {integrity: sha512-SVBVhNB+4zPL+rvtWLw7PZQkw/Eqj1HQZs22xtcqW36+xoifzEOEEDEpkxSMfB6RFeSIOcG00w6z5mSqLr1Y6w==} dev: true @@ -14307,16 +14335,10 @@ packages: ci-info: 3.2.0 dev: true - /is-core-module/2.2.0: - resolution: {integrity: sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==} - dependencies: - has: 1.0.3 - /is-core-module/2.4.0: resolution: {integrity: sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==} dependencies: has: 1.0.3 - dev: true /is-data-descriptor/0.1.4: resolution: {integrity: sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=} @@ -19979,7 +20001,7 @@ packages: /resolve/1.20.0: resolution: {integrity: sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==} dependencies: - is-core-module: 2.2.0 + is-core-module: 2.4.0 path-parse: 1.0.6 /responselike/1.0.2: @@ -20039,7 +20061,7 @@ packages: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} hasBin: true dependencies: - glob: 7.1.6 + glob: 7.1.7 dev: true /ripemd160/2.0.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e9ace118b15c..a6b11c284e2a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,3 +12,4 @@ packages: - 'packages/contracts' - 'packages/plugin-infra' - 'packages/plugins/example' + - 'packages/external-plugin-previewer' diff --git a/tsconfig.json b/tsconfig.json index 31c98cb0909a..08b0dfa4eaa0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ { "path": "./packages/storybook-shared/" }, { "path": "./packages/icons/" }, { "path": "./packages/plugin-infra" }, - { "path": "./packages/plugins/" } + { "path": "./packages/plugins/" }, + { "path": "./packages/external-plugin-previewer" } ], "compilerOptions": { /* Basic Options */