Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ GoodWidget/
core/ # @goodwidget/core — provider, hooks, EIP-1193, host detection
ui/ # @goodwidget/ui — Tamagui component library, theme system
embed/ # @goodwidget/embed — Web Component wrapper + CSS bridge
bridge/ # @goodwidget/bridge — iframe/WebView EIP-1193 bridge + EmbeddedWidget
claim-widget/ # @goodwidget/claim-widget — sample publishable widget (React + Web Component)

examples/
Expand All @@ -57,6 +58,9 @@ GoodWidget/
@goodwidget/core (depends on @goodwidget/ui for createGoodWidgetConfig, mergeThemeOverrides)
^
|
@goodwidget/bridge (depends on core — iframe/WebView provider bridge)
^
|
@goodwidget/embed (depends on core + ui, plus @r2wc/react-to-web-component)
```

Expand Down Expand Up @@ -234,6 +238,7 @@ custom element before being passed to `GoodWidgetProvider`.
| `@goodwidget/core` | tsup | ESM + CJS + `.d.ts` (two entry points: index, wagmi) |
| `@goodwidget/ui` | tsup | ESM + CJS + `.d.ts` |
| `@goodwidget/embed` | tsup | ESM + CJS + `.d.ts` |
| `@goodwidget/bridge` | tsup | ESM + CJS + `.d.ts` (four entry points: index, child, host, inject) |
| `@goodwidget/claim-widget` | tsup | ESM + CJS + `.d.ts` (three entry points: index, element, register) |
| `examples/react-web` | Vite + @vitejs/plugin-react | Static site |
| `examples/html` | Vite | Static site (bundles widget + React into a single JS file) |
Expand Down Expand Up @@ -346,6 +351,117 @@ See `packages/claim-widget/` for a complete working example.

---

## Package: `@goodwidget/bridge`

**NPM name:** `@goodwidget/bridge`
**Entry points:** `index.ts` (all), `child.ts` (embedded app), `host.ts` (embedding app), `inject.ts` (provider injection)
**Build:** tsup -> ESM + CJS + `.d.ts`

### Purpose

Bridges an EIP-1193 wallet provider from a host application to a third-party widget
running inside an **iframe** or **WebView**. The host's real wallet provider stays in
the parent context; the child receives a proxy provider that routes requests over
`postMessage`.

### Key files

| File | Responsibility |
|------|----------------|
| `src/protocol.ts` | Message envelope types (`init`, `init-ack`, `request`, `response`, `event`), namespace guard, version |
| `src/childProvider.ts` | `BridgeProvider` — EIP-1193-compatible class that sends requests via postMessage to the host |
| `src/hostRouter.ts` | `HostRouter` — listens for child messages, validates origins, forwards to real provider, broadcasts events |
| `src/inject.ts` | `injectBridgeProvider()` — sets `window.ethereum`, `window.goodWidget.provider`, announces via EIP-6963 |
| `src/enableIframeBridge.ts` | `enableIframeBridge()` — child opt-in helper: creates provider, performs handshake, injects |
| `src/createIframeBridgeHost.ts` | `createIframeBridgeHost()` — host-side convenience for a single iframe |
| `src/webviewInjection.ts` | `createWebViewBridgeScript()` — generates self-contained JS to inject into React Native WebView |
| `src/EmbeddedWidget.tsx` | `<EmbeddedWidget>` — React component that renders an iframe with auto-configured bridge |

### Bridge protocol (v1)

All messages carry `ns: 'gw-bridge'` and `version: '1.0.0'`. Messages without the
namespace are silently ignored.

**Handshake:**
1. Child sends `init` with optional `appId` and `capabilities`.
2. Host validates `event.origin` against its allowlist.
3. Host responds `init-ack` with a `sessionId` and optional `initialState` (accounts, chainId).

**RPC flow:**
1. Child sends `request` with `method`, `params`, `sessionId`.
2. Host calls `provider.request()` and sends `response` with `result` or `error`.

**Events:**
Host forwards `accountsChanged`, `chainChanged`, `connect`, `disconnect` as `event` messages.

### EIP-6963 integration

The bridge provider is announced via the standard EIP-6963 multi-provider discovery mechanism:

- **RDNS:** `org.gooddollar.goodwidget.bridge`
- **Name:** GoodWidget Bridge
- **Behavior:** `injectBridgeProvider()` dispatches `eip6963:announceProvider` and listens for
`eip6963:requestProvider` to re-announce.
- **Detection:** `discoverEIP6963Provider()` listens for announcements and resolves the first matching provider.
- **Scope:** Browser/iframe contexts. For React Native WebView, direct injection (`window.ethereum`) is canonical.

### Security model (medium)

- **Origin allowlist:** Host specifies `allowedOrigins`; child specifies `allowedParents`.
Messages from unknown origins are silently dropped.
- **Handshake gating:** No RPC requests are processed until the handshake completes.
- **Session binding:** Each child gets a unique `sessionId`; responses are scoped to the session.
- **No secret material crosses the bridge:** Private keys stay in the host wallet.
Only JSON-RPC request/response envelopes are exchanged.

### Iframe opt-in contract

A third-party widget opts in to iframe communication by calling `enableIframeBridge()` in
its entrypoint:

```ts
import { enableIframeBridge } from '@goodwidget/bridge/child'

const result = await enableIframeBridge({
allowedParents: ['https://host.app'],
appId: 'my-widget',
})
// result.provider is a full EIP-1193 provider
// Also injected as window.ethereum and announced via EIP-6963
```

If the widget is not in an iframe (`window.parent === window`), the call returns `null`.

### Auto-bridge in GoodWidgetProvider

`GoodWidgetProvider` automatically detects iframe/WebView contexts and attempts a
bridge handshake before any other provider detection:

1. On mount, calls `tryBridgeHandshake()` which sends a `gw-bridge` init message to `window.parent`.
2. If a host responds within 3 seconds, the bridge provider is installed as `window.goodWidget.provider`.
3. `detectHost()` then finds the bridge provider and uses it — **even if an explicit `provider` prop was also passed** (bridge always wins because the host is the source of truth when embedding).
4. If no host responds (not in an iframe, or host doesn't have the bridge), the timeout expires silently and detection falls through to other methods.

This means GoodWidget-based apps work in iframes **without any additional code** —
just using `<GoodWidgetProvider>` is enough. The `enableIframeBridge()` helper remains
available for non-GoodWidget apps or when you need manual control over injection/EIP-6963.

### Host detection priority

`detectHost()` in `@goodwidget/core` checks (in order):
1. GoodWidget bridge globals (`window.goodWidget.provider`, `window.ethereum.isGoodWidgetBridge`)
2. EIP-6963 discovered bridge provider (`rdns === 'org.gooddollar.goodwidget.bridge'`)
3. Explicit `provider` prop
4. Farcaster SDK
5. World App MiniKit
6. MiniPay (Celo)
7. Generic `window.ethereum`

**Bridge always wins:** if both a bridge and an explicit provider are available, the bridge
takes priority because the host embedding the iframe is the authoritative wallet source.

---

## Known Limitations and Future Work

- **No SSR support yet.** Tamagui CSS injection and Shadow DOM setup are client-only.
Expand Down
59 changes: 50 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ A cross-platform mini app framework for building web3 widgets that run inside wa

## Packages

| Package | Description |
|---------|-------------|
| `@goodwidget/core` | EIP-1193 provider normalization, host detection, wallet hooks, React context |
| `@goodwidget/ui` | Tamagui-based themeable component library (React + React Native Web) |
| `@goodwidget/embed` | Web Component wrapper for embedding mini apps in any HTML page |
| `@goodwidget/claim-widget` | Sample publishable widget — React component + Web Component |
| Package | Description |
| -------------------------- | ---------------------------------------------------------------------------- |
| `@goodwidget/core` | EIP-1193 provider normalization, host detection, wallet hooks, React context |
| `@goodwidget/ui` | Tamagui-based themeable component library (React + React Native Web) |
| `@goodwidget/embed` | Web Component wrapper for embedding mini apps in any HTML page |
| `@goodwidget/bridge` | Iframe/WebView EIP-1193 bridge, EmbeddedWidget component, EIP-6963 support |
| `@goodwidget/claim-widget` | Sample publishable widget — React component + Web Component |

## Quick Start

Expand Down Expand Up @@ -91,9 +92,9 @@ const config = createGoodWidgetConfig({

```css
good-miniapp {
--gw-color-primary: #FF6B00;
--gw-Card-background: #FFF3E0;
--gw-Button-background: #FF6B00;
--gw-color-primary: #ff6b00;
--gw-Card-background: #fff3e0;
--gw-Button-background: #ff6b00;
}
```

Expand Down Expand Up @@ -121,6 +122,45 @@ customElements.define('my-miniapp', Element)
</script>
```

## Embedding Third-Party Widgets (Iframe/WebView)

### As a host (React)

```tsx
import { EmbeddedWidget } from '@goodwidget/bridge'

;<EmbeddedWidget
src="https://widget.example.com"
provider={walletProvider}
allowedOrigins={['https://widget.example.com']}
themeOverrides={{ tokens: { color: { primary: '#E91E63' } } }}
onReady={() => console.log('connected')}
style={{ width: '100%', height: 400, border: 'none' }}
/>
```

### As a host (WebView / React Native)

```ts
import { createWebViewBridgeScript } from '@goodwidget/bridge/host'

const injectedJS = createWebViewBridgeScript({ eip6963: true })
// Pass to <WebView injectedJavaScript={injectedJS} onMessage={...} />
```

### As the embedded widget (opt-in)

```ts
import { enableIframeBridge } from '@goodwidget/bridge/child'

const result = await enableIframeBridge({
allowedParents: ['https://host.app'],
appId: 'my-widget',
})
// result.provider is window.ethereum (bridged to host wallet)
// Also announced via EIP-6963 (rdns: org.gooddollar.goodwidget.bridge)
```

## Creating Custom Components

Use `createComponent()` to ensure your components are theme-overridable by hosts:
Expand Down Expand Up @@ -165,6 +205,7 @@ GoodWidget/
core/ → @goodwidget/core (provider, hooks, EIP-1193, host detection)
ui/ → @goodwidget/ui (component library, theme system)
embed/ → @goodwidget/embed (Web Component wrapper)
bridge/ → @goodwidget/bridge (iframe/WebView EIP-1193 bridge)
claim-widget/ → @goodwidget/claim-widget (sample publishable widget)
examples/
react-web/ → React demo with style override showcase
Expand Down
46 changes: 46 additions & 0 deletions docs/PACKAGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,3 +605,49 @@ Consumers of this package:
| `examples/react-web/` | React web app importing `ClaimWidget` component |
| `examples/html/` | Plain HTML page using `<gw-claim-widget>` element |
| `examples/expo/` | Expo React Native app importing GoodWidget components |

---

## Enabling Iframe/WebView Embedding

If your widget will be loaded inside an iframe or WebView by a host app, you need to
opt in to bridge communication so the host's wallet provider is accessible.

### 1. Add `@goodwidget/bridge` as a dependency

```bash
pnpm add @goodwidget/bridge
```

### 2. Call `enableIframeBridge()` in your entrypoint

```ts
// src/main.ts or src/index.ts
import { enableIframeBridge } from '@goodwidget/bridge/child'

const bridge = await enableIframeBridge({
allowedParents: ['https://host1.app', 'https://host2.app'],
appId: 'my-widget',
})

if (bridge) {
// Running in an iframe — bridge.provider is a full EIP-1193 provider
// Also injected as window.ethereum and announced via EIP-6963
console.log('Connected to host, session:', bridge.sessionId)
}

// Then render your app as normal — GoodWidgetProvider will auto-detect the provider
```

### 3. Security considerations

- Always specify `allowedParents` — don't use `['*']` in production.
- The bridge only exposes JSON-RPC request/response envelopes; private keys never
leave the host wallet.
- The host controls which origins can communicate; both sides must agree.

### 4. EIP-6963 compatibility

The bridge provider is announced via EIP-6963 with `rdns: 'org.gooddollar.goodwidget.bridge'`.
This means any dapp that uses the standard multi-provider discovery flow (e.g. wagmi, web3-onboard)
will automatically detect it, even without reading `window.ethereum` directly.
2 changes: 1 addition & 1 deletion examples/expo/.expo/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"urlRandomness": "jgvGdH8"
"urlRandomness": "XNDOASU"
}
23 changes: 23 additions & 0 deletions examples/expo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,29 @@ The app shows four override strategies applied to the **same** ClaimWidget:

- **`app/index.tsx`** — Main screen showing all four override levels vertically
- **`app/theme-demo.tsx`** — Side-by-side comparison of the same widget with different themes
- **`app/webview-bridge.tsx`** — Live WebView bridge demo using `createWebViewBridgeConfig()` from `@goodwidget/bridge/host`

### WebView bridge helper

This example includes a real end-to-end WebView host setup using a single helper:

```ts
import { createWebViewBridgeConfig } from '@goodwidget/bridge/host'

const bridge = createWebViewBridgeConfig({
provider, // host EIP-1193 provider
sendToWebView: (message) => webViewRef.current?.postMessage(message),
})

<WebView
injectedJavaScript={bridge.injectedJavaScript}
onMessage={(event) => void bridge.onMessage(event)}
/>
```

The helper bundles both:
- injected script creation (`window.ethereum` + `window.goodWidget.provider` + EIP-6963)
- host-side request/response callback (`onMessage`) for forwarding RPC to the real provider

## Notes

Expand Down
18 changes: 18 additions & 0 deletions examples/expo/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import React from 'react'
import { SafeAreaView, StyleSheet } from 'react-native'
import { Link } from 'expo-router'
import { ClaimWidget } from '@goodwidget/claim-widget'
import {
Card,
Heading,
Text,
Alert,
Separator,
Button,
ButtonText,
YStack,
} from '@goodwidget/ui'

Expand Down Expand Up @@ -108,6 +111,21 @@ export default function HomeScreen() {
},
}}
/>

<Separator />

<Card>
<Heading level={5}>WebView Bridge Demo</Heading>
<Text secondary>
Open a real WebView demo where a child page calls
window.ethereum through the bridge host helper.
</Text>
<Link href="/webview-bridge" asChild>
<Button fullWidth>
<ButtonText>Open WebView Bridge Screen</ButtonText>
</Button>
</Link>
</Card>
</YStack>
</SafeAreaView>
)
Expand Down
Loading