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 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">
|
||||||
|
|||||||
@@ -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