diff --git a/CLAUDE.md b/CLAUDE.md index 1b9f0e1..12af01b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 8dfba94..b9f2760 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) | diff --git a/docs/react.md b/docs/react.md new file mode 100644 index 0000000..0cfecb5 --- /dev/null +++ b/docs/react.md @@ -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 ( + <> + + + {wc.uri && wc.qrUri && ( + wc.disconnect()} + uri={wc.uri} + qrUri={wc.qrUri} + /> + )} + + {wc.state === "connected" && ( +

Connected to {wc.walletName}

+ )} + + ); +} +``` + +## 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; + 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 + { + 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 + +``` + +## 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. diff --git a/eslint.config.cjs b/eslint.config.cjs index 9bde924..aa30523 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -8,7 +8,7 @@ module.exports = [ ignores: ["**/dist/**", "**/node_modules/**", "**/*.js", "**/*.cjs"], }, { - files: ["**/*.ts"], + files: ["**/*.ts", "**/*.tsx"], languageOptions: { parser, parserOptions: { diff --git a/linters/copyright_check.mjs b/linters/copyright_check.mjs index 225addc..e0f84f8 100644 --- a/linters/copyright_check.mjs +++ b/linters/copyright_check.mjs @@ -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) { diff --git a/package-lock.json b/package-lock.json index fca0f35..f9949ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e6c884b..b1a6ea1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 0000000..ddf0f88 --- /dev/null +++ b/packages/react/README.md @@ -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"; + + 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"; + +; +``` + +## 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 — disconnect and clean up + error, // string | null — error message + } = useWizardConnect({ + dappName: "My Dapp", + dappIcon: "https://example.com/icon.png", + }); + + return ( + <> + {state === "idle" && } + {state === "connected" && Connected to {walletName}} + + {uri && 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). diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..96203af --- /dev/null +++ b/packages/react/package.json @@ -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" + } +} diff --git a/packages/react/src/components/AlphanumericQRCode.tsx b/packages/react/src/components/AlphanumericQRCode.tsx new file mode 100644 index 0000000..e320da7 --- /dev/null +++ b/packages/react/src/components/AlphanumericQRCode.tsx @@ -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(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 ( + + ); +} diff --git a/packages/react/src/components/WizardConnectQRDialog.test.tsx b/packages/react/src/components/WizardConnectQRDialog.test.tsx new file mode 100644 index 0000000..cec9a57 --- /dev/null +++ b/packages/react/src/components/WizardConnectQRDialog.test.tsx @@ -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[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((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"); + }); +}); diff --git a/packages/react/src/components/WizardConnectQRDialog.tsx b/packages/react/src/components/WizardConnectQRDialog.tsx new file mode 100644 index 0000000..f6f6bf5 --- /dev/null +++ b/packages/react/src/components/WizardConnectQRDialog.tsx @@ -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 ( + + + + ); +} + +function CopyIcon({ color }: { color: string }) { + return ( + + + + + ); +} + +/** + * 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( +
+
+ {/* Header */} +
+
+ + + {title} + +
+ +
+ + {/* Body */} +
+

+ {subtitle} +

+ + {/* QR container */} +
+ +
+ + {/* Copy URI row */} +
+

+ {uri} +

+ +
+
+
+
, + document.body, + ); +} diff --git a/packages/react/src/components/logo.ts b/packages/react/src/components/logo.ts new file mode 100644 index 0000000..38165bf --- /dev/null +++ b/packages/react/src/components/logo.ts @@ -0,0 +1,7 @@ +// 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 + +/** WizardConnect logo embedded as a data URI (60x60 PNG, ~4KB). */ +export const WIZARDCONNECT_LOGO = + "data:image/png;base64,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"; diff --git a/packages/react/src/hooks/useWizardConnect.test.ts b/packages/react/src/hooks/useWizardConnect.test.ts new file mode 100644 index 0000000..70b805c --- /dev/null +++ b/packages/react/src/hooks/useWizardConnect.test.ts @@ -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; + off: ReturnType; + emit: ReturnType; + }; + })(), + 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(); +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[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((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((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((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((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((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((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((r) => setTimeout(r, 0)); + expect(localStorage.getItem(SESSION_KEY)).not.toBeNull(); + + await result.current.disconnect(); + await new Promise((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((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((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((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((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((r) => setTimeout(r, 0)); + + const callArgs = vi.mocked(initiateDappRelay).mock.calls[0]; + expect(callArgs[1]).toEqual( + expect.objectContaining({ + explicitRelayUrls: ["wss://custom-relay:443"], + }), + ); + }); +}); diff --git a/packages/react/src/hooks/useWizardConnect.ts b/packages/react/src/hooks/useWizardConnect.ts new file mode 100644 index 0000000..1e88cb4 --- /dev/null +++ b/packages/react/src/hooks/useWizardConnect.ts @@ -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("idle"); + const [manager, setManager] = useState(null); + const [uri, setUri] = useState(null); + const [qrUri, setQrUri] = useState(null); + const [walletName, setWalletName] = useState(null); + const [walletIcon, setWalletIcon] = useState(null); + const [error, setError] = useState(null); + + const relayRef = useRef(null); + const managerRef = useRef(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 => { + 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, + }; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 0000000..73c252c --- /dev/null +++ b/packages/react/src/index.ts @@ -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"; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts new file mode 100644 index 0000000..f96315b --- /dev/null +++ b/packages/react/src/types.ts @@ -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; + /** Error message if connection failed. */ + error: string | null; +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 0000000..9339009 --- /dev/null +++ b/packages/react/tsconfig.json @@ -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" + ] +} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 0000000..5312019 --- /dev/null +++ b/packages/react/vitest.config.ts @@ -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/**"], + }, +});