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:
jwinterm
2026-06-07 19:11:05 -04:00
parent 35b5bc8cda
commit 60de8c8fe5
3 changed files with 183 additions and 0 deletions
+22
View File
@@ -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;
}
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist_Mono } from "next/font/google"; import { Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { SmirkBridgeBootstrap } from "./SmirkBridgeBootstrap";
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
variable: "--font-geist-mono", variable: "--font-geist-mono",
@@ -46,6 +47,7 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body className={`${geistMono.variable} font-mono antialiased min-h-screen flex flex-col`}> <body className={`${geistMono.variable} font-mono antialiased min-h-screen flex flex-col`}>
<SmirkBridgeBootstrap />
<main className="flex-1 flex flex-col">{children}</main> <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"> <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"> <a href="/stats" className="hover:text-[#fbeb0a] transition-colors">
+159
View File
@@ -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'));
}