Add 'react' package with QR code and modal dialog
Makes it easier for dapp developers to integrate WizardConnect and have consistent user interface for it across dapps.
This commit is contained in:
parent
e2476626c2
commit
45fd9f4cb5
19 changed files with 1744 additions and 3 deletions
|
|
@ -64,6 +64,7 @@ Protocol and architecture documentation lives in `docs/`. Keep it up to date whe
|
|||
| `docs/transport.md` | `RelayClient`, `initiateRelay`, reconnect logic, or encryption scheme changes |
|
||||
| `docs/wallet.md` | `WalletAdapter`, `WalletConnectionManager`, or connection lifecycle changes |
|
||||
| `docs/dapp.md` | `DappConnectionManager` API or session lifecycle changes |
|
||||
| `docs/react.md` | React components, hooks, or QR dialog API changes |
|
||||
| `docs/pubkey-derivation.md` | xpub delivery, `DappPubkeyStateManager`, or gap-fill logic changes |
|
||||
| `docs/xpub-sharing.md` | xpub sharing rationale, security model, or comparison with other protocols changes |
|
||||
| `docs/index.md` | New top-level docs files are added |
|
||||
|
|
@ -71,7 +72,9 @@ Protocol and architecture documentation lives in `docs/`. Keep it up to date whe
|
|||
## Packages
|
||||
|
||||
- `@wizardconnect/core` — transport + protocol primitives (relay client, key exchange, hdwalletv1 message types)
|
||||
- `@wizardconnect/dapp` — dapp-side helpers (`DappConnectionManager`, `DappPubkeyStateManager`)
|
||||
- `@wizardconnect/wallet` — wallet-side helpers (`WalletConnectionManager`, `WalletAdapter`, `PubkeyStateManager`)
|
||||
- `@wizardconnect/react` — React components and hooks (`WizardConnectQRDialog`, `AlphanumericQRCode`, `useWizardConnect`)
|
||||
- `@wizardconnect/test-cli` — manual test CLI (`dapp` and `wallet` modes)
|
||||
|
||||
## Build
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ libwizardconnect/
|
|||
│ DappConnectionManager, DappPubkeyStateManager.
|
||||
│ Single-session dapp helper, on-demand xpub derivation.
|
||||
│
|
||||
├── packages/react — @wizardconnect/react
|
||||
│ React components and hooks for dapp integration.
|
||||
│ QR dialog, useWizardConnect hook.
|
||||
│
|
||||
└── packages/test-cli — @wizardconnect/test-cli (private)
|
||||
CLI for manual and exploratory testing.
|
||||
```
|
||||
|
|
@ -76,4 +80,5 @@ future if needed. You will be prompted to sign the CLA when you open your first
|
|||
| Relay transport and encryption | [transport.md](transport.md) |
|
||||
| Wallet integration guide | [wallet.md](wallet.md) |
|
||||
| Dapp integration guide | [dapp.md](dapp.md) |
|
||||
| React components and hooks | [react.md](react.md) |
|
||||
| xpub delivery and pubkey derivation | [pubkey-derivation.md](pubkey-derivation.md) |
|
||||
|
|
|
|||
147
docs/react.md
Normal file
147
docs/react.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# React integration
|
||||
|
||||
`@wizardconnect/react` provides React components and hooks for dapp developers who want to add
|
||||
WizardConnect wallet connectivity without writing relay boilerplate.
|
||||
|
||||
## Quick start
|
||||
|
||||
```tsx
|
||||
import { useWizardConnect, WizardConnectQRDialog } from "@wizardconnect/react";
|
||||
|
||||
function App() {
|
||||
const wc = useWizardConnect({ dappName: "My Dapp" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={wc.connect} disabled={wc.state !== "idle"}>
|
||||
Connect Wallet
|
||||
</button>
|
||||
|
||||
{wc.uri && wc.qrUri && (
|
||||
<WizardConnectQRDialog
|
||||
show={wc.state === "connecting"}
|
||||
onClose={() => wc.disconnect()}
|
||||
uri={wc.uri}
|
||||
qrUri={wc.qrUri}
|
||||
/>
|
||||
)}
|
||||
|
||||
{wc.state === "connected" && (
|
||||
<p>Connected to {wc.walletName}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## useWizardConnect
|
||||
|
||||
The `useWizardConnect` hook manages the full relay lifecycle:
|
||||
|
||||
1. **`connect()`** — calls `initiateDappRelay()`, creates a `DappConnectionManager`, and returns
|
||||
the connection URI for QR display.
|
||||
2. **Key exchange** — listens for `keyexchangecomplete` and persists the wallet's public key to
|
||||
localStorage for auto-reconnect.
|
||||
3. **`walletready`** — updates `state` to `"connected"` and populates `walletName`/`walletIcon`.
|
||||
4. **Auto-reconnect** — on mount, checks localStorage for a stored session with a `walletPublicKey`
|
||||
and automatically reconnects.
|
||||
5. **`disconnect()`** — sends a courtesy disconnect, cleans up the relay, and clears session storage.
|
||||
|
||||
### Return value
|
||||
|
||||
```typescript
|
||||
interface UseWizardConnectResult {
|
||||
state: "idle" | "connecting" | "connected" | "disconnected";
|
||||
manager: DappConnectionManager | null;
|
||||
uri: string | null;
|
||||
qrUri: string | null;
|
||||
walletName: string | null;
|
||||
walletIcon: string | null;
|
||||
connect: () => boolean;
|
||||
disconnect: () => Promise<void>;
|
||||
error: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Using the manager
|
||||
|
||||
Once `state === "connected"`, the `manager` is a live `DappConnectionManager` from
|
||||
`@wizardconnect/dapp`. Use it to derive addresses and request signatures:
|
||||
|
||||
```typescript
|
||||
// Derive a receive address pubkey
|
||||
const pubkey = wc.manager.getPubkey(0, 0n);
|
||||
|
||||
// Send a sign request
|
||||
const seq = wc.manager.nextSequence();
|
||||
const response = await wc.manager.sendSignRequest({ ... });
|
||||
```
|
||||
|
||||
For most dapps, you'll wrap the manager in an app-specific wallet adapter (like
|
||||
`RelayWalletDapp` in the Cauldron and Moria codebases).
|
||||
|
||||
## WizardConnectQRDialog
|
||||
|
||||
A framework-independent modal dialog for displaying the connection QR code. Uses inline styles
|
||||
(no Tailwind or CSS framework dependency) with a dark theme by default.
|
||||
|
||||
### Customization
|
||||
|
||||
Colors, text, and logos are all customizable via props:
|
||||
|
||||
```tsx
|
||||
<WizardConnectQRDialog
|
||||
show={true}
|
||||
onClose={handleClose}
|
||||
uri={connection.uri}
|
||||
qrUri={connection.qrUri}
|
||||
title="My Protocol"
|
||||
subtitle="Scan to pair your wallet"
|
||||
logoUrl="/my-logo.png"
|
||||
theme={{
|
||||
dialogBackground: "#0f172a",
|
||||
headerBackground: "#0f172a",
|
||||
borderColor: "#334155",
|
||||
}}
|
||||
onCopy={(uri) => {
|
||||
navigator.clipboard.writeText(uri);
|
||||
showToast("Copied!");
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
The `onCopy` callback replaces the default `navigator.clipboard.writeText` behavior, which is
|
||||
useful when the dapp has its own toast/notification system.
|
||||
|
||||
## AlphanumericQRCode
|
||||
|
||||
A standalone canvas-based QR code component. Uses QR Alphanumeric mode with error correction
|
||||
level H (30% recovery), which allows a center logo overlay without breaking the code.
|
||||
|
||||
```tsx
|
||||
<AlphanumericQRCode
|
||||
value="WIZ://..."
|
||||
size={280}
|
||||
foreground="#1e2a4a"
|
||||
background="#ffffff"
|
||||
logoUrl="/logo.png"
|
||||
/>
|
||||
```
|
||||
|
||||
## Session persistence
|
||||
|
||||
By default, `useWizardConnect` persists session credentials (private key, shared secret, and
|
||||
wallet public key) to localStorage under the `wizardconnect-session` key. This enables
|
||||
auto-reconnect when the user refreshes the page.
|
||||
|
||||
The persistence key and behavior can be customized:
|
||||
|
||||
```typescript
|
||||
useWizardConnect({
|
||||
sessionKey: "my-app-wc-session", // custom localStorage key
|
||||
persistSession: false, // disable persistence entirely
|
||||
});
|
||||
```
|
||||
|
||||
Credentials are only stored after the key exchange completes. On disconnect, the stored session
|
||||
is cleared.
|
||||
|
|
@ -8,7 +8,7 @@ module.exports = [
|
|||
ignores: ["**/dist/**", "**/node_modules/**", "**/*.js", "**/*.cjs"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
languageOptions: {
|
||||
parser,
|
||||
parserOptions: {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const REQUIRED_LINES = [
|
|||
// Matches: "Copyright (C) 2026 Whiterun LLC" or "Copyright (C) 2024-2026 Whiterun LLC"
|
||||
const COPYRIGHT_PATTERN = /Copyright \(C\) (\d{4}-)?\d{4} Whiterun LLC/;
|
||||
|
||||
const EXTENSIONS = [".ts", ".mjs"];
|
||||
const EXTENSIONS = [".ts", ".tsx", ".mjs"];
|
||||
const DIRECTORIES = ["packages", "linters"];
|
||||
|
||||
function walk(dir) {
|
||||
|
|
|
|||
182
package-lock.json
generated
182
package-lock.json
generated
|
|
@ -1339,12 +1339,57 @@
|
|||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||
|
|
@ -1707,6 +1752,10 @@
|
|||
"resolved": "packages/dapp",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@wizardconnect/react": {
|
||||
"resolved": "packages/react",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@wizardconnect/test-cli": {
|
||||
"resolved": "packages/test-cli",
|
||||
"link": true
|
||||
|
|
@ -1984,6 +2033,13 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -2058,6 +2114,19 @@
|
|||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
|
|
@ -2490,6 +2559,24 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "20.8.4",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz",
|
||||
"integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"entities": "^7.0.1",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-html": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
|
||||
|
|
@ -2756,6 +2843,26 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify/node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lossless-json": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz",
|
||||
|
|
@ -3271,6 +3378,39 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz",
|
||||
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
||||
|
|
@ -3366,6 +3506,16 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
|
@ -4011,6 +4161,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -4127,6 +4287,28 @@
|
|||
"vitest": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"packages/react": {
|
||||
"name": "@wizardconnect/react",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@wizardconnect/core": "*",
|
||||
"@wizardconnect/dapp": "*",
|
||||
"qrcode-generator": "^1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"happy-dom": "^20.8.4",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"packages/test-cli": {
|
||||
"name": "@wizardconnect/test-cli",
|
||||
"version": "0.1.0",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build -w packages/core -w packages/dapp -w packages/wallet -w packages/test-cli",
|
||||
"build": "npm run build -w packages/core -w packages/dapp -w packages/wallet -w packages/react -w packages/test-cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"test:integration": "npm run test:integration --workspaces --if-present",
|
||||
"dapp": "npm run dapp --workspace @wizardconnect/test-cli",
|
||||
|
|
|
|||
155
packages/react/README.md
Normal file
155
packages/react/README.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# @wizardconnect/react
|
||||
|
||||
React components and hooks for integrating WizardConnect into dapps.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @wizardconnect/react @wizardconnect/core @wizardconnect/dapp
|
||||
```
|
||||
|
||||
React 18+ is required as a peer dependency.
|
||||
|
||||
## Components
|
||||
|
||||
### WizardConnectQRDialog
|
||||
|
||||
A portal-based modal dialog that displays a WizardConnect QR code for wallet pairing. Uses inline styles for framework independence (no Tailwind or CSS framework required).
|
||||
|
||||
```tsx
|
||||
import { WizardConnectQRDialog } from "@wizardconnect/react";
|
||||
|
||||
<WizardConnectQRDialog
|
||||
show={showDialog}
|
||||
onClose={() => setShowDialog(false)}
|
||||
uri={connection.uri}
|
||||
qrUri={connection.qrUri}
|
||||
logoUrl="/my-logo.png"
|
||||
theme={{
|
||||
dialogBackground: "#1e293b",
|
||||
headerBackground: "#1e293b",
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ----------------------- | ------------------------------------ | --------------------------------------------------- |
|
||||
| `show` | `boolean` | _required_ | Whether the dialog is visible |
|
||||
| `onClose` | `() => void` | _required_ | Called when the user clicks close or the backdrop |
|
||||
| `uri` | `string` | _required_ | Human-readable URI to display (`wiz://...`) |
|
||||
| `qrUri` | `string` | _required_ | Alphanumeric-safe URI for QR encoding (`WIZ://...`) |
|
||||
| `onCopy` | `(uri: string) => void` | `navigator.clipboard.writeText` | Called when copy button is clicked |
|
||||
| `theme` | `WizardConnectQRTheme` | dark theme defaults | Color overrides |
|
||||
| `title` | `string` | `"WizardConnect"` | Dialog title |
|
||||
| `subtitle` | `string` | `"Scan with your wallet to connect"` | Subtitle text |
|
||||
| `logoUrl` | `string` | none | Logo for the header |
|
||||
| `className` | `string` | none | Additional CSS class on the outermost container |
|
||||
|
||||
### AlphanumericQRCode
|
||||
|
||||
A standalone canvas-based QR code renderer. Uses Alphanumeric mode with error correction level H (30% recovery) to tolerate a center logo overlay.
|
||||
|
||||
```tsx
|
||||
import { AlphanumericQRCode } from "@wizardconnect/react";
|
||||
|
||||
<AlphanumericQRCode
|
||||
value="WIZ://..."
|
||||
size={280}
|
||||
foreground="#1e2a4a"
|
||||
background="#ffffff"
|
||||
logoUrl="/logo.png"
|
||||
/>;
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### useWizardConnect
|
||||
|
||||
Encapsulates the full WizardConnect relay lifecycle: relay initiation, `DappConnectionManager` management, key exchange events, session persistence, and auto-reconnect.
|
||||
|
||||
```tsx
|
||||
import { useWizardConnect, WizardConnectQRDialog } from "@wizardconnect/react";
|
||||
|
||||
function ConnectButton() {
|
||||
const {
|
||||
state, // "idle" | "connecting" | "connected" | "disconnected"
|
||||
manager, // DappConnectionManager (null until connect())
|
||||
uri, // connection URI (null until connect())
|
||||
qrUri, // QR-safe URI (null until connect())
|
||||
walletName, // wallet name (null until walletready)
|
||||
walletIcon, // wallet icon (null until walletready)
|
||||
connect, // () => boolean — initiate a new connection
|
||||
disconnect, // () => Promise<void> — disconnect and clean up
|
||||
error, // string | null — error message
|
||||
} = useWizardConnect({
|
||||
dappName: "My Dapp",
|
||||
dappIcon: "https://example.com/icon.png",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{state === "idle" && <button onClick={connect}>Connect</button>}
|
||||
{state === "connected" && <span>Connected to {walletName}</span>}
|
||||
|
||||
{uri && qrUri && (
|
||||
<WizardConnectQRDialog
|
||||
show={state === "connecting"}
|
||||
onClose={disconnect}
|
||||
uri={uri}
|
||||
qrUri={qrUri}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------- | ---------- | ------------------------- | ------------------------------------------ |
|
||||
| `dappName` | `string` | none | Display name sent in `dapp_ready` |
|
||||
| `dappIcon` | `string` | none | Icon URL sent in `dapp_ready` |
|
||||
| `relayUrls` | `string[]` | default relay | Explicit relay WebSocket URLs |
|
||||
| `sessionKey` | `string` | `"wizardconnect-session"` | localStorage key for session persistence |
|
||||
| `persistSession` | `boolean` | `true` | Whether to save session for auto-reconnect |
|
||||
|
||||
**Using the `manager`:**
|
||||
|
||||
After `state` becomes `"connected"`, use `manager` to build your app-specific wallet adapter. The manager provides:
|
||||
|
||||
- `getPubkey(childIndex, addressIndex)` — derive pubkeys from xpubs
|
||||
- `sendSignRequest(request)` — request transaction signatures
|
||||
- `sendSignCancel(sequence)` — cancel an in-flight sign request
|
||||
- `on("walletready", callback)` — listen for wallet handshake completion
|
||||
|
||||
See the [`@wizardconnect/dapp` documentation](../../docs/dapp.md) for the full `DappConnectionManager` API.
|
||||
|
||||
## Theme customization
|
||||
|
||||
All colors in `WizardConnectQRDialog` can be overridden via the `theme` prop:
|
||||
|
||||
```tsx
|
||||
const myTheme: WizardConnectQRTheme = {
|
||||
backdropColor: "rgba(0,0,0,0.5)",
|
||||
dialogBackground: "#1a1f2e",
|
||||
headerBackground: "#1a1f2e",
|
||||
titleColor: "#ffffff",
|
||||
subtitleColor: "#9ca3af",
|
||||
qrForeground: "#1e2a4a",
|
||||
qrBackground: "#ffffff",
|
||||
uriRowBackground: "rgba(31,41,55,0.6)",
|
||||
uriTextColor: "#9ca3af",
|
||||
borderColor: "#374151",
|
||||
closeButtonColor: "#9ca3af",
|
||||
copyButtonColor: "#9ca3af",
|
||||
logoUrl: "/my-qr-logo.png",
|
||||
qrSize: 280,
|
||||
};
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
LGPL-3.0-or-later. See [LICENSE](../../LICENSE).
|
||||
44
packages/react/package.json
Normal file
44
packages/react/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@wizardconnect/react",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "React components and hooks for WizardConnect dapp integration",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitlab.com/riftenlabs/lib/wizardconnect.git",
|
||||
"directory": "packages/react"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
|
||||
"lint:prettier": "prettier --ignore-path ../../.gitignore . --list-different",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint": "npm run lint:eslint && npm run lint:prettier",
|
||||
"fix": "npm run fix:eslint && npm run fix:prettier",
|
||||
"fix:prettier": "prettier --ignore-path ../../.gitignore . --write",
|
||||
"fix:eslint": "npm run lint:eslint -- --fix"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wizardconnect/core": "*",
|
||||
"@wizardconnect/dapp": "*",
|
||||
"qrcode-generator": "^1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"happy-dom": "^20.8.4",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
100
packages/react/src/components/AlphanumericQRCode.tsx
Normal file
100
packages/react/src/components/AlphanumericQRCode.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import qrGenerator from "qrcode-generator";
|
||||
import { WIZARDCONNECT_LOGO } from "./logo.js";
|
||||
import type { AlphanumericQRCodeProps } from "../types.js";
|
||||
|
||||
const DEFAULT_SIZE = 280;
|
||||
const DEFAULT_QUIET_ZONE = 4;
|
||||
const DEFAULT_FG = "#1e2a4a";
|
||||
const DEFAULT_BG = "#ffffff";
|
||||
|
||||
/**
|
||||
* Renders a QR code on a canvas using explicit Alphanumeric mode.
|
||||
* Optionally overlays a logo in the center (uses error correction H).
|
||||
*/
|
||||
export function AlphanumericQRCode({
|
||||
value,
|
||||
size = DEFAULT_SIZE,
|
||||
foreground = DEFAULT_FG,
|
||||
background = DEFAULT_BG,
|
||||
quietZone = DEFAULT_QUIET_ZONE,
|
||||
}: AlphanumericQRCodeProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Use error correction H (30% recovery) to tolerate the center logo
|
||||
const qr = qrGenerator(0, "H");
|
||||
qr.addData(value, "Alphanumeric");
|
||||
qr.make();
|
||||
|
||||
const moduleCount = qr.getModuleCount();
|
||||
const cellSize = size / moduleCount;
|
||||
const canvasSize = size + 2 * quietZone;
|
||||
const scale =
|
||||
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
||||
|
||||
canvas.width = canvasSize * scale;
|
||||
canvas.height = canvasSize * scale;
|
||||
canvas.style.width = `${canvasSize}px`;
|
||||
canvas.style.height = `${canvasSize}px`;
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, canvasSize, canvasSize);
|
||||
|
||||
// Draw QR modules
|
||||
ctx.fillStyle = foreground;
|
||||
for (let row = 0; row < moduleCount; row++) {
|
||||
for (let col = 0; col < moduleCount; col++) {
|
||||
if (qr.isDark(row, col)) {
|
||||
ctx.fillRect(
|
||||
Math.round(col * cellSize) + quietZone,
|
||||
Math.round(row * cellSize) + quietZone,
|
||||
Math.ceil(cellSize),
|
||||
Math.ceil(cellSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay WizardConnect logo in the center
|
||||
{
|
||||
const logo = new Image();
|
||||
logo.onload = () => {
|
||||
const logoSize = size * 0.22;
|
||||
const logoPadding = 4;
|
||||
const dx = (canvasSize - logoSize) / 2;
|
||||
const dy = (canvasSize - logoSize) / 2;
|
||||
|
||||
// Clear rectangular area behind logo
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(
|
||||
dx - logoPadding,
|
||||
dy - logoPadding,
|
||||
logoSize + logoPadding * 2,
|
||||
logoSize + logoPadding * 2,
|
||||
);
|
||||
|
||||
ctx.drawImage(logo, dx, dy, logoSize, logoSize);
|
||||
};
|
||||
logo.src = WIZARDCONNECT_LOGO;
|
||||
}
|
||||
}, [value, size, foreground, background, quietZone]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ height: "auto", maxWidth: `${size}px`, width: "100%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
157
packages/react/src/components/WizardConnectQRDialog.test.tsx
Normal file
157
packages/react/src/components/WizardConnectQRDialog.test.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { WizardConnectQRDialog } from "./WizardConnectQRDialog.js";
|
||||
|
||||
// Mock the QR component to avoid canvas issues in happy-dom
|
||||
vi.mock("./AlphanumericQRCode.js", () => ({
|
||||
AlphanumericQRCode: (props: { value: string }) =>
|
||||
createElement("div", {
|
||||
"data-testid": "qr-code",
|
||||
"data-value": props.value,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("WizardConnectQRDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
// Clean up portals
|
||||
const portals = document.querySelectorAll("[style*='position: fixed']");
|
||||
portals.forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
function render(
|
||||
props: Partial<Parameters<typeof WizardConnectQRDialog>[0]> = {},
|
||||
) {
|
||||
const defaultProps = {
|
||||
show: true,
|
||||
onClose: () => {},
|
||||
uri: "wiz://test",
|
||||
qrUri: "WIZ://TEST",
|
||||
};
|
||||
root.render(
|
||||
createElement(WizardConnectQRDialog, { ...defaultProps, ...props }),
|
||||
);
|
||||
// Flush synchronous React work
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
it("renders nothing when show is false", async () => {
|
||||
await render({ show: false });
|
||||
|
||||
const backdrops = document.querySelectorAll("[style*='position: fixed']");
|
||||
expect(backdrops.length).toBe(0);
|
||||
});
|
||||
|
||||
it("renders a portal modal when show is true", async () => {
|
||||
await render({ show: true });
|
||||
|
||||
const backdrops = document.querySelectorAll("[style*='position: fixed']");
|
||||
expect(backdrops.length).toBe(1);
|
||||
});
|
||||
|
||||
it("displays the default title", async () => {
|
||||
await render();
|
||||
|
||||
expect(document.body.textContent).toContain("WizardConnect");
|
||||
});
|
||||
|
||||
it("displays a custom title", async () => {
|
||||
await render({ title: "My Custom Title" });
|
||||
|
||||
expect(document.body.textContent).toContain("My Custom Title");
|
||||
});
|
||||
|
||||
it("displays the URI text", async () => {
|
||||
await render({ uri: "wiz://my-unique-uri" });
|
||||
|
||||
expect(document.body.textContent).toContain("wiz://my-unique-uri");
|
||||
});
|
||||
|
||||
it("displays the default subtitle", async () => {
|
||||
await render();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
"Scan with your wallet to connect",
|
||||
);
|
||||
});
|
||||
|
||||
it("displays a custom subtitle", async () => {
|
||||
await render({ subtitle: "Custom scan instructions" });
|
||||
|
||||
expect(document.body.textContent).toContain("Custom scan instructions");
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
await render({ onClose });
|
||||
|
||||
const closeButton = document.querySelector(
|
||||
"button[aria-label='Close']",
|
||||
) as HTMLButtonElement;
|
||||
expect(closeButton).not.toBeNull();
|
||||
closeButton.click();
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onCopy with the URI when copy button is clicked", async () => {
|
||||
const onCopy = vi.fn();
|
||||
await render({ uri: "wiz://copy-test", onCopy });
|
||||
|
||||
const copyButton = document.querySelector(
|
||||
"button[aria-label='Copy URI']",
|
||||
) as HTMLButtonElement;
|
||||
expect(copyButton).not.toBeNull();
|
||||
copyButton.click();
|
||||
|
||||
expect(onCopy).toHaveBeenCalledOnce();
|
||||
expect(onCopy).toHaveBeenCalledWith("wiz://copy-test");
|
||||
});
|
||||
|
||||
it("applies custom theme colors", async () => {
|
||||
await render({
|
||||
theme: {
|
||||
dialogBackground: "#ff0000",
|
||||
headerBackground: "#00ff00",
|
||||
},
|
||||
});
|
||||
|
||||
const elements = document.querySelectorAll("[style*='background-color']");
|
||||
const styles = Array.from(elements).map(
|
||||
(el) => (el as HTMLElement).style.backgroundColor,
|
||||
);
|
||||
expect(styles).toContain("#00ff00");
|
||||
expect(styles).toContain("#ff0000");
|
||||
});
|
||||
|
||||
it("always renders the WizardConnect logo in the header", async () => {
|
||||
await render();
|
||||
|
||||
const logo = document.querySelector("img") as HTMLImageElement;
|
||||
expect(logo).not.toBeNull();
|
||||
expect(logo.src).toContain("data:image/png;base64,");
|
||||
});
|
||||
|
||||
it("passes qrUri to the QR code component", async () => {
|
||||
await render({ qrUri: "WIZ://MY-QR-VALUE" });
|
||||
|
||||
const qrCode = document.querySelector("[data-testid='qr-code']");
|
||||
expect(qrCode).not.toBeNull();
|
||||
expect(qrCode?.getAttribute("data-value")).toBe("WIZ://MY-QR-VALUE");
|
||||
});
|
||||
});
|
||||
262
packages/react/src/components/WizardConnectQRDialog.tsx
Normal file
262
packages/react/src/components/WizardConnectQRDialog.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlphanumericQRCode } from "./AlphanumericQRCode.js";
|
||||
import { WIZARDCONNECT_LOGO } from "./logo.js";
|
||||
import type {
|
||||
WizardConnectQRDialogProps,
|
||||
WizardConnectQRTheme,
|
||||
} from "../types.js";
|
||||
|
||||
const defaults: Required<
|
||||
Pick<
|
||||
WizardConnectQRTheme,
|
||||
| "backdropColor"
|
||||
| "dialogBackground"
|
||||
| "headerBackground"
|
||||
| "titleColor"
|
||||
| "subtitleColor"
|
||||
| "qrForeground"
|
||||
| "qrBackground"
|
||||
| "uriRowBackground"
|
||||
| "uriTextColor"
|
||||
| "borderColor"
|
||||
| "closeButtonColor"
|
||||
| "copyButtonColor"
|
||||
| "qrSize"
|
||||
>
|
||||
> = {
|
||||
backdropColor: "rgba(0,0,0,0.5)",
|
||||
dialogBackground: "#1a1f2e",
|
||||
headerBackground: "#1a1f2e",
|
||||
titleColor: "#ffffff",
|
||||
subtitleColor: "#9ca3af",
|
||||
qrForeground: "#1e2a4a",
|
||||
qrBackground: "#ffffff",
|
||||
uriRowBackground: "rgba(31,41,55,0.6)",
|
||||
uriTextColor: "#9ca3af",
|
||||
borderColor: "#374151",
|
||||
closeButtonColor: "#9ca3af",
|
||||
copyButtonColor: "#9ca3af",
|
||||
qrSize: 280,
|
||||
};
|
||||
|
||||
function CloseIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M5 5L15 15M15 5L5 15"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<rect
|
||||
x="9"
|
||||
y="9"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A portal-based modal dialog that displays a WizardConnect QR code
|
||||
* for wallet pairing. Framework-independent (inline styles, no Tailwind).
|
||||
*/
|
||||
export function WizardConnectQRDialog({
|
||||
show,
|
||||
onClose,
|
||||
uri,
|
||||
qrUri,
|
||||
onCopy,
|
||||
theme,
|
||||
className,
|
||||
subtitle = "Scan with your wallet to connect",
|
||||
title = "WizardConnect",
|
||||
}: WizardConnectQRDialogProps) {
|
||||
const t = { ...defaults, ...theme };
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (onCopy) {
|
||||
onCopy(uri);
|
||||
} else if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(uri).catch(() => {});
|
||||
}
|
||||
}, [uri, onCopy]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={className}
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 40,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: "16px",
|
||||
backgroundColor: t.backdropColor,
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "384px",
|
||||
borderRadius: "16px",
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 25px 50px -12px rgba(0,0,0,0.5)",
|
||||
border: `1px solid ${t.borderColor}`,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "16px 20px",
|
||||
backgroundColor: t.headerBackground,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
<img
|
||||
src={WIZARDCONNECT_LOGO}
|
||||
alt=""
|
||||
style={{ width: "28px", height: "28px" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: t.titleColor,
|
||||
fontWeight: 600,
|
||||
fontSize: "18px",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon color={t.closeButtonColor} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: t.dialogBackground,
|
||||
padding: "0 20px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: t.subtitleColor, fontSize: "14px", margin: 0 }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
{/* QR container */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: t.qrBackground,
|
||||
padding: "12px",
|
||||
borderRadius: "12px",
|
||||
}}
|
||||
>
|
||||
<AlphanumericQRCode
|
||||
value={qrUri}
|
||||
size={t.qrSize}
|
||||
foreground={t.qrForeground}
|
||||
background={t.qrBackground}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Copy URI row */}
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "10px 12px",
|
||||
backgroundColor: t.uriRowBackground,
|
||||
borderRadius: "8px",
|
||||
border: `1px solid ${t.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: t.uriTextColor,
|
||||
fontSize: "12px",
|
||||
margin: 0,
|
||||
flex: 1,
|
||||
wordBreak: "break-all",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
}}
|
||||
>
|
||||
{uri}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
aria-label="Copy URI"
|
||||
>
|
||||
<CopyIcon color={t.copyButtonColor} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
7
packages/react/src/components/logo.ts
Normal file
7
packages/react/src/components/logo.ts
Normal file
File diff suppressed because one or more lines are too long
299
packages/react/src/hooks/useWizardConnect.test.ts
Normal file
299
packages/react/src/hooks/useWizardConnect.test.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { UseWizardConnectResult } from "../types.js";
|
||||
|
||||
// Mock @wizardconnect/core before importing the hook
|
||||
vi.mock("@wizardconnect/core", () => {
|
||||
const EventEmitter = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
}));
|
||||
|
||||
return {
|
||||
initiateDappRelay: vi.fn(() => ({
|
||||
client: {},
|
||||
uri: "wiz://test-uri",
|
||||
qrUri: "WIZ://TEST-URI",
|
||||
credentials: {
|
||||
privateKey: "a".repeat(64),
|
||||
publicKey: "b".repeat(64),
|
||||
secret: "c".repeat(16),
|
||||
},
|
||||
events: new (EventEmitter as unknown as {
|
||||
new (): {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
emit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
})(),
|
||||
cleanup: vi.fn(),
|
||||
})),
|
||||
binToHex: vi.fn((bytes: Uint8Array) =>
|
||||
Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join(""),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@wizardconnect/dapp", () => {
|
||||
return {
|
||||
DappConnectionManager: vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
walletName: null,
|
||||
walletIcon: null,
|
||||
updateConnection: vi.fn(),
|
||||
sendDisconnect: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Import AFTER mocks are set up
|
||||
const { useWizardConnect } = await import("./useWizardConnect.js");
|
||||
const { initiateDappRelay } = await import("@wizardconnect/core");
|
||||
|
||||
// Mock localStorage since happy-dom's implementation is incomplete
|
||||
const storage = new Map<string, string>();
|
||||
const mockLocalStorage = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
clear: () => storage.clear(),
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
key: (_index: number) => null,
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: mockLocalStorage,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe("useWizardConnect", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const SESSION_KEY = "wc-test-session";
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
storage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
storage.clear();
|
||||
});
|
||||
|
||||
/** Renders the hook inside a test component, returning a ref to the latest result. */
|
||||
async function renderHook(
|
||||
options?: Parameters<typeof useWizardConnect>[0],
|
||||
): Promise<{ result: { current: UseWizardConnectResult } }> {
|
||||
const result: { current: UseWizardConnectResult } = {
|
||||
current: null as unknown as UseWizardConnectResult,
|
||||
};
|
||||
|
||||
function TestComponent() {
|
||||
const hookResult = useWizardConnect({
|
||||
sessionKey: SESSION_KEY,
|
||||
...options,
|
||||
});
|
||||
result.current = hookResult;
|
||||
return null;
|
||||
}
|
||||
|
||||
root.render(createElement(TestComponent));
|
||||
// Wait for React to process the render
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
return { result };
|
||||
}
|
||||
|
||||
it("starts in idle state", async () => {
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
|
||||
expect(result.current.state).toBe("idle");
|
||||
expect(result.current.manager).toBeNull();
|
||||
expect(result.current.uri).toBeNull();
|
||||
expect(result.current.qrUri).toBeNull();
|
||||
expect(result.current.walletName).toBeNull();
|
||||
expect(result.current.walletIcon).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("transitions to connecting on connect()", async () => {
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(result.current.state).toBe("connecting");
|
||||
expect(result.current.manager).not.toBeNull();
|
||||
expect(result.current.uri).toBe("wiz://test-uri");
|
||||
expect(result.current.qrUri).toBe("WIZ://TEST-URI");
|
||||
});
|
||||
|
||||
it("connect() calls initiateDappRelay", async () => {
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(initiateDappRelay).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("connect() returns false when already connecting", async () => {
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
|
||||
const firstResult = result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
const secondResult = result.current.connect();
|
||||
|
||||
expect(firstResult).toBe(true);
|
||||
expect(secondResult).toBe(false);
|
||||
});
|
||||
|
||||
it("saves credentials to localStorage on connect when persistSession is true", async () => {
|
||||
const { result } = await renderHook({ persistSession: true });
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
const stored = localStorage.getItem(SESSION_KEY);
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.privateKey).toBe("a".repeat(64));
|
||||
expect(parsed.secret).toBe("c".repeat(16));
|
||||
});
|
||||
|
||||
it("does not save credentials when persistSession is false", async () => {
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("disconnect() clears state and localStorage", async () => {
|
||||
const { result } = await renderHook({ persistSession: true });
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
expect(localStorage.getItem(SESSION_KEY)).not.toBeNull();
|
||||
|
||||
await result.current.disconnect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(result.current.state).toBe("idle");
|
||||
expect(result.current.manager).toBeNull();
|
||||
expect(result.current.uri).toBeNull();
|
||||
expect(result.current.qrUri).toBeNull();
|
||||
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("attempts auto-reconnect when stored session has walletPublicKey", async () => {
|
||||
localStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
walletPublicKey: "f".repeat(64),
|
||||
}),
|
||||
);
|
||||
|
||||
await renderHook({ persistSession: true });
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(initiateDappRelay).toHaveBeenCalledOnce();
|
||||
|
||||
// Should pass existing credentials
|
||||
const callArgs = vi.mocked(initiateDappRelay).mock.calls[0];
|
||||
expect(callArgs[1]).toEqual({
|
||||
existingCredentials: {
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
walletPublicKey: "f".repeat(64),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-reconnect when stored session lacks walletPublicKey", async () => {
|
||||
localStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
}),
|
||||
);
|
||||
|
||||
await renderHook({ persistSession: true });
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(initiateDappRelay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-reconnect when persistSession is false", async () => {
|
||||
localStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
walletPublicKey: "f".repeat(64),
|
||||
}),
|
||||
);
|
||||
|
||||
await renderHook({ persistSession: false });
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(initiateDappRelay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes dappName and dappIcon to DappConnectionManager", async () => {
|
||||
const { DappConnectionManager } = vi.mocked(
|
||||
await import("@wizardconnect/dapp"),
|
||||
);
|
||||
|
||||
const { result } = await renderHook({
|
||||
dappName: "My Dapp",
|
||||
dappIcon: "https://example.com/icon.png",
|
||||
persistSession: false,
|
||||
});
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(DappConnectionManager).toHaveBeenCalledWith(
|
||||
"My Dapp",
|
||||
"https://example.com/icon.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes relayUrls to initiateDappRelay", async () => {
|
||||
const { result } = await renderHook({
|
||||
relayUrls: ["wss://custom-relay:443"],
|
||||
persistSession: false,
|
||||
});
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
const callArgs = vi.mocked(initiateDappRelay).mock.calls[0];
|
||||
expect(callArgs[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
explicitRelayUrls: ["wss://custom-relay:443"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
214
packages/react/src/hooks/useWizardConnect.ts
Normal file
214
packages/react/src/hooks/useWizardConnect.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
initiateDappRelay,
|
||||
type DappRelayResult,
|
||||
type RelayUpdatePayload,
|
||||
binToHex,
|
||||
} from "@wizardconnect/core";
|
||||
import { DappConnectionManager } from "@wizardconnect/dapp";
|
||||
import type {
|
||||
UseWizardConnectOptions,
|
||||
UseWizardConnectResult,
|
||||
WizardConnectState,
|
||||
} from "../types.js";
|
||||
|
||||
const DEFAULT_SESSION_KEY = "wizardconnect-session";
|
||||
|
||||
interface StoredSession {
|
||||
privateKey: string;
|
||||
secret: string;
|
||||
walletPublicKey?: string;
|
||||
}
|
||||
|
||||
function loadSession(key: string): StoredSession | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as StoredSession;
|
||||
if (!parsed.privateKey || !parsed.secret) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSession(key: string, session: StoredSession): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(key, JSON.stringify(session));
|
||||
}
|
||||
|
||||
function clearSession(key: string): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that encapsulates the WizardConnect relay lifecycle.
|
||||
*
|
||||
* Manages: relay initiation, DappConnectionManager, key exchange events,
|
||||
* session persistence, and auto-reconnect on page refresh.
|
||||
*
|
||||
* The returned `manager` can be used to build an app-specific wallet adapter.
|
||||
*/
|
||||
export function useWizardConnect(
|
||||
options: UseWizardConnectOptions = {},
|
||||
): UseWizardConnectResult {
|
||||
const {
|
||||
dappName,
|
||||
dappIcon,
|
||||
relayUrls,
|
||||
sessionKey = DEFAULT_SESSION_KEY,
|
||||
persistSession = true,
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<WizardConnectState>("idle");
|
||||
const [manager, setManager] = useState<DappConnectionManager | null>(null);
|
||||
const [uri, setUri] = useState<string | null>(null);
|
||||
const [qrUri, setQrUri] = useState<string | null>(null);
|
||||
const [walletName, setWalletName] = useState<string | null>(null);
|
||||
const [walletIcon, setWalletIcon] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const relayRef = useRef<DappRelayResult | null>(null);
|
||||
const managerRef = useRef<DappConnectionManager | null>(null);
|
||||
const autoReconnectAttempted = useRef(false);
|
||||
|
||||
const startRelay = useCallback(
|
||||
(existingCredentials?: {
|
||||
privateKey: string;
|
||||
secret: string;
|
||||
walletPublicKey: string;
|
||||
}): boolean => {
|
||||
if (state === "connecting" || state === "connected") return false;
|
||||
|
||||
setError(null);
|
||||
setState("connecting");
|
||||
|
||||
const mgr = new DappConnectionManager(dappName, dappIcon);
|
||||
managerRef.current = mgr;
|
||||
setManager(mgr);
|
||||
|
||||
mgr.on("walletready", () => {
|
||||
setWalletName(mgr.walletName);
|
||||
setWalletIcon(mgr.walletIcon);
|
||||
setState("connected");
|
||||
});
|
||||
|
||||
mgr.on("disconnect", () => {
|
||||
setState("disconnected");
|
||||
setWalletName(null);
|
||||
setWalletIcon(null);
|
||||
});
|
||||
|
||||
try {
|
||||
const relay = initiateDappRelay(
|
||||
(payload: RelayUpdatePayload) => {
|
||||
mgr.updateConnection(payload.client, payload.status);
|
||||
},
|
||||
{
|
||||
existingCredentials,
|
||||
explicitRelayUrls: relayUrls,
|
||||
},
|
||||
);
|
||||
|
||||
relayRef.current = relay;
|
||||
setUri(relay.uri);
|
||||
setQrUri(relay.qrUri);
|
||||
|
||||
if (persistSession) {
|
||||
saveSession(sessionKey, {
|
||||
privateKey: relay.credentials.privateKey,
|
||||
secret: relay.credentials.secret,
|
||||
});
|
||||
}
|
||||
|
||||
relay.events.on(
|
||||
"keyexchangecomplete",
|
||||
(walletPublicKey: Uint8Array) => {
|
||||
if (persistSession) {
|
||||
const stored = loadSession(sessionKey);
|
||||
if (stored) {
|
||||
stored.walletPublicKey = binToHex(walletPublicKey);
|
||||
saveSession(sessionKey, stored);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : "Failed to start relay";
|
||||
setError(message);
|
||||
setState("idle");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[state, dappName, dappIcon, relayUrls, sessionKey, persistSession],
|
||||
);
|
||||
|
||||
const connect = useCallback((): boolean => {
|
||||
return startRelay();
|
||||
}, [startRelay]);
|
||||
|
||||
const disconnect = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (managerRef.current) {
|
||||
await managerRef.current.sendDisconnect().catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
relayRef.current?.cleanup();
|
||||
relayRef.current = null;
|
||||
managerRef.current = null;
|
||||
setManager(null);
|
||||
setUri(null);
|
||||
setQrUri(null);
|
||||
setWalletName(null);
|
||||
setWalletIcon(null);
|
||||
setState("idle");
|
||||
if (persistSession) {
|
||||
clearSession(sessionKey);
|
||||
}
|
||||
}
|
||||
}, [persistSession, sessionKey]);
|
||||
|
||||
// Auto-reconnect on mount if a stored session exists
|
||||
useEffect(() => {
|
||||
if (autoReconnectAttempted.current) return;
|
||||
if (!persistSession) return;
|
||||
autoReconnectAttempted.current = true;
|
||||
|
||||
const stored = loadSession(sessionKey);
|
||||
if (!stored || !stored.walletPublicKey) return;
|
||||
|
||||
startRelay({
|
||||
privateKey: stored.privateKey,
|
||||
secret: stored.secret,
|
||||
walletPublicKey: stored.walletPublicKey,
|
||||
});
|
||||
}, [persistSession, sessionKey, startRelay]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
relayRef.current?.cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
manager,
|
||||
uri,
|
||||
qrUri,
|
||||
walletName,
|
||||
walletIcon,
|
||||
connect,
|
||||
disconnect,
|
||||
error,
|
||||
};
|
||||
}
|
||||
15
packages/react/src/index.ts
Normal file
15
packages/react/src/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
export { WizardConnectQRDialog } from "./components/WizardConnectQRDialog.js";
|
||||
export { AlphanumericQRCode } from "./components/AlphanumericQRCode.js";
|
||||
export { useWizardConnect } from "./hooks/useWizardConnect.js";
|
||||
export type {
|
||||
WizardConnectQRDialogProps,
|
||||
WizardConnectQRTheme,
|
||||
AlphanumericQRCodeProps,
|
||||
UseWizardConnectOptions,
|
||||
UseWizardConnectResult,
|
||||
WizardConnectState,
|
||||
} from "./types.js";
|
||||
115
packages/react/src/types.ts
Normal file
115
packages/react/src/types.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import type { DappConnectionManager } from "@wizardconnect/dapp";
|
||||
|
||||
// ---- QR Code ----
|
||||
|
||||
export interface AlphanumericQRCodeProps {
|
||||
/** The alphanumeric string to encode. */
|
||||
value: string;
|
||||
/** QR code pixel size. Default: 280 */
|
||||
size?: number;
|
||||
/** Module (dark cell) color. Default: "#1e2a4a" */
|
||||
foreground?: string;
|
||||
/** Background color. Default: "#ffffff" */
|
||||
background?: string;
|
||||
/** Quiet zone size in pixels. Default: 4 */
|
||||
quietZone?: number;
|
||||
}
|
||||
|
||||
// ---- QR Dialog ----
|
||||
|
||||
export interface WizardConnectQRTheme {
|
||||
/** Backdrop overlay color. Default: "rgba(0,0,0,0.5)" */
|
||||
backdropColor?: string;
|
||||
/** Dialog body background. Default: "#1a1f2e" */
|
||||
dialogBackground?: string;
|
||||
/** Header bar background. Default: "#1a1f2e" */
|
||||
headerBackground?: string;
|
||||
/** Title text color. Default: "#ffffff" */
|
||||
titleColor?: string;
|
||||
/** Subtitle text color. Default: "#9ca3af" */
|
||||
subtitleColor?: string;
|
||||
/** QR foreground (module) color. Default: "#1e2a4a" */
|
||||
qrForeground?: string;
|
||||
/** QR background color. Default: "#ffffff" */
|
||||
qrBackground?: string;
|
||||
/** URI display row background. Default: "rgba(31,41,55,0.6)" */
|
||||
uriRowBackground?: string;
|
||||
/** URI text color. Default: "#9ca3af" */
|
||||
uriTextColor?: string;
|
||||
/** Border color. Default: "#374151" */
|
||||
borderColor?: string;
|
||||
/** Close button color. Default: "#9ca3af" */
|
||||
closeButtonColor?: string;
|
||||
/** Copy button color. Default: "#9ca3af" */
|
||||
copyButtonColor?: string;
|
||||
/** QR code size in pixels. Default: 280 */
|
||||
qrSize?: number;
|
||||
}
|
||||
|
||||
export interface WizardConnectQRDialogProps {
|
||||
/** Whether the dialog is visible. */
|
||||
show: boolean;
|
||||
/** Called when the user clicks close or the backdrop. */
|
||||
onClose: () => void;
|
||||
/** The human-readable URI to display (lowercase wiz://...). */
|
||||
uri: string;
|
||||
/** The QR-alphanumeric-safe URI for encoding (uppercase WIZ://...). */
|
||||
qrUri: string;
|
||||
/** Called when the user clicks the copy button. Receives the uri string.
|
||||
* If not provided, uses navigator.clipboard.writeText(). */
|
||||
onCopy?: (uri: string) => void;
|
||||
/** Theme overrides. */
|
||||
theme?: WizardConnectQRTheme;
|
||||
/** Additional CSS class on the outermost container. */
|
||||
className?: string;
|
||||
/** Subtitle text. Default: "Scan with your wallet to connect" */
|
||||
subtitle?: string;
|
||||
/** Title text. Default: "WizardConnect" */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// ---- Hook ----
|
||||
|
||||
export type WizardConnectState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected";
|
||||
|
||||
export interface UseWizardConnectOptions {
|
||||
/** Display name of the dapp. */
|
||||
dappName?: string;
|
||||
/** Icon URL/data-URI of the dapp. */
|
||||
dappIcon?: string;
|
||||
/** Explicit relay URLs. */
|
||||
relayUrls?: string[];
|
||||
/** LocalStorage key for session persistence. Default: "wizardconnect-session" */
|
||||
sessionKey?: string;
|
||||
/** Whether to persist session for auto-reconnect. Default: true */
|
||||
persistSession?: boolean;
|
||||
}
|
||||
|
||||
export interface UseWizardConnectResult {
|
||||
/** Current connection state. */
|
||||
state: WizardConnectState;
|
||||
/** The DappConnectionManager (null before connect()). */
|
||||
manager: DappConnectionManager | null;
|
||||
/** The connection URI (null before connect()). */
|
||||
uri: string | null;
|
||||
/** The QR-alphanumeric URI (null before connect()). */
|
||||
qrUri: string | null;
|
||||
/** Wallet name (null until walletready). */
|
||||
walletName: string | null;
|
||||
/** Wallet icon (null until walletready). */
|
||||
walletIcon: string | null;
|
||||
/** Initiate a new connection. Returns false if already connecting/connected. */
|
||||
connect: () => boolean;
|
||||
/** Disconnect and clean up. */
|
||||
disconnect: () => Promise<void>;
|
||||
/** Error message if connection failed. */
|
||||
error: string | null;
|
||||
}
|
||||
17
packages/react/tsconfig.json
Normal file
17
packages/react/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx"
|
||||
]
|
||||
}
|
||||
19
packages/react/vitest.config.ts
Normal file
19
packages/react/vitest.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import path from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@wizardconnect/core": path.resolve(__dirname, "../core/src/index.ts"),
|
||||
"@wizardconnect/dapp": path.resolve(__dirname, "../dapp/src/index.ts"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
exclude: ["**/*.integration.test.ts", "**/node_modules/**"],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue