mirror of
https://github.com/Such-Software/smirk-website.git
synced 2026-10-17 05:51:28 +02:00
feat: install postMessage transport for v0.3.0+ Smirk wallet iframes
Adds a runtime that auto-detects parent-frame context and installs window.smirk via postMessage, so smirk.cash works inside the v0.3.0 Smirk desktop wallet's embedded browser the same way it already works under the v0.2.x browser extension. Extension-injected window.smirk takes precedence, so existing dapps and the v0.2.x flow are unchanged. Local mirror of @smirk/dapp-api's installSmirkPageApi pending that package being published to npm; see the file header for the sync contract.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* `SmirkBridgeBootstrap` — mounts the `installSmirkPageApi` runtime
|
||||
* on every page of smirk.cash so the site works both inside the
|
||||
* v0.2.x browser extension (no-op; extension wins) and inside the
|
||||
* v0.3.0+ desktop wallet's embedded iframe (postMessage transport).
|
||||
*
|
||||
* Lives in the root layout so every route picks it up automatically —
|
||||
* page.tsx, TipPageClient.tsx, future id/forum subdomains, all of it.
|
||||
* No other files need to know about the v0.3.0 transport.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { installSmirkPageApi } from '@/lib/smirk-iframe-bridge';
|
||||
|
||||
export function SmirkBridgeBootstrap(): null {
|
||||
useEffect(() => {
|
||||
installSmirkPageApi();
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { SmirkBridgeBootstrap } from "./SmirkBridgeBootstrap";
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
@@ -46,6 +47,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistMono.variable} font-mono antialiased min-h-screen flex flex-col`}>
|
||||
<SmirkBridgeBootstrap />
|
||||
<main className="flex-1 flex flex-col">{children}</main>
|
||||
<footer className="py-4 text-center text-xs text-zinc-500 flex items-center justify-center gap-3 flex-wrap">
|
||||
<a href="/stats" className="hover:text-[#fbeb0a] transition-colors">
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* `installSmirkPageApi` — runtime installer for `window.smirk`.
|
||||
*
|
||||
* Local copy of `@smirk/dapp-api`'s `installSmirkPageApi`, kept here
|
||||
* until that package is published to npm. **When @smirk/dapp-api is
|
||||
* available, replace this file with:**
|
||||
*
|
||||
* ```ts
|
||||
* export { installSmirkPageApi } from '@smirk/dapp-api';
|
||||
* ```
|
||||
*
|
||||
* Source of truth lives at
|
||||
* `smirk-monorepo/packages/dapp-api/src/install-page-api.ts`.
|
||||
* Any change to this file should be mirrored there (and vice versa).
|
||||
*
|
||||
* Behaviour:
|
||||
*
|
||||
* 1. If `window.smirk` is already defined, the v0.2.x browser
|
||||
* extension content script already ran. We leave it alone —
|
||||
* every existing dapp continues to work untouched.
|
||||
*
|
||||
* 2. If `window.parent !== window`, the page is iframed by something.
|
||||
* The Smirk v0.3.0 desktop wallet's embedded browser is the
|
||||
* relevant case. We install a `window.smirk` whose every method
|
||||
* posts a `SMIRK_REQUEST` envelope to `window.parent` and resolves
|
||||
* on the matching response. UI surface (approval modals etc.) is
|
||||
* the wallet's responsibility.
|
||||
*
|
||||
* 3. Otherwise (no extension, no iframe parent) we install nothing.
|
||||
* The page's existing "extension not found" fallback applies.
|
||||
*
|
||||
* Safe to call multiple times. Returns a hint at what it did.
|
||||
*/
|
||||
|
||||
const DEFAULT_POSTMESSAGE_CHANNEL = 'smirk:dapp';
|
||||
const PROTOCOL_VERSION = 1;
|
||||
|
||||
export interface InstallSmirkPageApiOptions {
|
||||
readonly channel?: string;
|
||||
readonly timeoutMs?: number;
|
||||
readonly mode?: 'auto' | 'force' | 'never';
|
||||
readonly onReady?: () => void;
|
||||
}
|
||||
|
||||
export function installSmirkPageApi(
|
||||
options: InstallSmirkPageApiOptions = {},
|
||||
): 'extension-present' | 'iframe-mode' | 'none' {
|
||||
if (typeof window === 'undefined') return 'none';
|
||||
|
||||
if (typeof (window as Window & { smirk?: unknown }).smirk !== 'undefined') {
|
||||
return 'extension-present';
|
||||
}
|
||||
|
||||
const mode = options.mode ?? 'auto';
|
||||
if (mode === 'never') return 'none';
|
||||
if (mode === 'auto' && window.parent === window) return 'none';
|
||||
|
||||
const channel = options.channel ?? DEFAULT_POSTMESSAGE_CHANNEL;
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
|
||||
installPostMessageRuntime(channel, timeoutMs);
|
||||
options.onReady?.();
|
||||
return 'iframe-mode';
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface WireResponse {
|
||||
type: 'SMIRK_RESPONSE';
|
||||
v: number;
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
||||
function installPostMessageRuntime(channel: string, timeoutMs: number): void {
|
||||
const pending = new Map<number, PendingEntry>();
|
||||
let nextId = 1;
|
||||
|
||||
function request(method: string, params: unknown): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(
|
||||
Object.assign(new Error(`Smirk wallet timed out responding to ${method}`), {
|
||||
code: 'TIMEOUT',
|
||||
}),
|
||||
);
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
window.parent.postMessage(
|
||||
{
|
||||
channel,
|
||||
payload: {
|
||||
type: 'SMIRK_REQUEST',
|
||||
v: PROTOCOL_VERSION,
|
||||
id,
|
||||
method,
|
||||
params: params ?? {},
|
||||
},
|
||||
},
|
||||
'*',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('message', (ev: MessageEvent) => {
|
||||
const data = ev.data as
|
||||
| { channel?: unknown; payload?: WireResponse }
|
||||
| null
|
||||
| undefined;
|
||||
if (!data || data.channel !== channel) return;
|
||||
const resp = data.payload;
|
||||
if (!resp || resp.type !== 'SMIRK_RESPONSE') return;
|
||||
const entry = pending.get(resp.id);
|
||||
if (!entry) return;
|
||||
pending.delete(resp.id);
|
||||
clearTimeout(entry.timer);
|
||||
if (resp.error) {
|
||||
entry.reject(
|
||||
Object.assign(new Error(resp.error.message), { code: resp.error.code }),
|
||||
);
|
||||
} else {
|
||||
entry.resolve(resp.result);
|
||||
}
|
||||
});
|
||||
|
||||
const api = {
|
||||
isInstalled: (): boolean => true,
|
||||
protocolVersion: (): number => PROTOCOL_VERSION,
|
||||
isUnlocked: (): Promise<boolean> => request('isUnlocked', {}) as Promise<boolean>,
|
||||
isConnected: (): Promise<boolean> => request('isConnected', {}) as Promise<boolean>,
|
||||
connect: (assets?: string[]): Promise<unknown> => request('connect', { assets }),
|
||||
disconnect: (): Promise<void> => request('disconnect', {}) as Promise<void>,
|
||||
getAddresses: (assets?: string[]): Promise<unknown> =>
|
||||
request('getAddresses', { assets }),
|
||||
getPublicKeys: (assets?: string[]): Promise<unknown> =>
|
||||
request('getPublicKeys', { assets }),
|
||||
signMessage: (message: string, assets?: string[]): Promise<unknown> =>
|
||||
request('signMessage', { message, assets }),
|
||||
requestPayment: (req: unknown): Promise<unknown> =>
|
||||
request('requestPayment', req),
|
||||
claimPublicTip: (tipId: string, fragmentKey: string): Promise<unknown> =>
|
||||
request('claimPublicTip', { tipId, fragmentKey }),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'smirk', {
|
||||
value: api,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('smirk-ready'));
|
||||
}
|
||||
Reference in New Issue
Block a user