Merge branch 'pkg-react' into 'master'
Add 'react' package with QR code and modal dialog See merge request riftenlabs/lib/wizardconnect!11
This commit is contained in:
commit
33c45596ef
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