(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 = (
+ }
+ />
+ )
+ return (
+
+
+ New unknown Mask plugins found. Do you want to load them?
+ }>
+ {plugins.map((x) => (
+ set(x.url, !get(x.url))} key={x.url}>
+
+
+
+
+
+ ))}
+
+
+
+
+ Load
+
+ Load All
+
+
+ )
+}
+
+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 */