55d873548d
The iframe.diy-based sandbox infrastructure powered two features: the nsite preview dialog (Run button on NsiteCard and AppHandlerContent) and the webxdc embed (cartridge launcher + sandboxed iframe runtime with kind 4932/20932 sync events). Both are removed wholesale: - iframe sandbox plumbing: SandboxFrame, src/lib/sandbox/*, iframeSubdomain, previewInjectedScript. - nsite preview: NsitePreviewDialog, NsitePermissionManager, NsitePermissionPrompt, useNsiteSignerRpc, nsitePermissions, nsiteNostrProvider, NsitePlayerContext. - webxdc runtime: WebxdcEmbed, Webxdc, GameControls, useWebxdc, webxdcMeta, public/cartridge.png, NOSTR_WEBXDC.md, @webxdc/types dependency, kindLabels/signerWithNudge entries for kinds 4932/20932. - AppConfig: drop sandboxDomain, showWebxdc, feedIncludeWebxdc. - extraKinds: drop kind 1063 webxdc entry; sidebar drops 'webxdc'. - ComposeBox: .xdc uploads now flow through the generic file path (no UUID injection, no manifest extraction, .xdc removed from the file picker accept list). - NoteContent / FileMetadataContent: webxdc branches removed; .xdc attachments fall through to the generic file card. - LayoutContext / CenterColumnContext: only consumed by the removed fullscreen preview panels — deleted along with its provider in AppRouter. NsiteCard keeps its rich link-preview card but loses the Run button and preview dialog. AppHandlerContent keeps a kind 35128 `a`-tag reference but replaces 'Run' with an external 'Open Site' link to `<pubkeyB36><dTag>.nsite.lol`. The standard HTML iframe `sandbox` attribute used by SpotifyEmbed/TweetEmbed/RedditEmbed is unrelated to iframe.diy and stays.
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
import type { NostrEvent, NostrSigner } from '@nostrify/types';
|
|
import { createElement } from 'react';
|
|
import { toast } from '@/hooks/useToast';
|
|
import { NudgeToastContent } from '@/components/SignerToastContent';
|
|
import { type BtcSigner, hasBtcSigning } from '@/lib/bitcoin-signers';
|
|
import { getKindLabel } from '@/lib/kindLabels';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Show the nudge toast after this delay if a signer op is still pending. */
|
|
const NUDGE_DELAY_MS = 4_000;
|
|
|
|
|
|
/** Hard timeout — reject the op entirely after this long with no response. */
|
|
const HARD_TIMEOUT_MS = 45_000;
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function isAndroid(): boolean {
|
|
return typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
|
|
}
|
|
|
|
type OpType = 'sign' | 'encrypt' | 'decrypt';
|
|
|
|
/**
|
|
* Context-specific overrides for nudge toast descriptions.
|
|
* Falls back to the central kind label registry for kinds not listed here.
|
|
*/
|
|
const NUDGE_OVERRIDES: Record<number, string> = {
|
|
0: 'profile update',
|
|
1: 'post',
|
|
3: 'contact list update',
|
|
11: 'post',
|
|
8333: 'Bitcoin zap',
|
|
10000: 'mute list update',
|
|
10001: 'pinned notes update',
|
|
10002: 'relay list update',
|
|
10003: 'bookmarks update',
|
|
10015: 'interests update',
|
|
10030: 'emoji list update',
|
|
24242: 'file upload auth',
|
|
30000: 'user list update',
|
|
30078: 'app settings',
|
|
30315: 'status update',
|
|
31925: 'event RSVP',
|
|
39089: 'help content',
|
|
};
|
|
|
|
function labelForOp(kind: number | undefined, opType: OpType): string {
|
|
if (kind !== undefined) {
|
|
if (NUDGE_OVERRIDES[kind]) return NUDGE_OVERRIDES[kind];
|
|
const central = getKindLabel(kind, '');
|
|
if (central) return central.toLowerCase();
|
|
}
|
|
if (opType === 'encrypt') return 'encryption';
|
|
if (opType === 'decrypt') return 'decryption';
|
|
return 'signing';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sentinel values used to signal control flow inside Promise.race
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CANCEL = Symbol('cancel');
|
|
const TIMEOUT = Symbol('timeout');
|
|
|
|
type Signal = typeof CANCEL | typeof TIMEOUT;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Toast deduplication — prevent a storm of identical nudge toasts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Timestamp of the last nudge toast shown. Used to throttle. */
|
|
let lastNudgeShownAt = 0;
|
|
/** Minimum gap between nudge toasts (ms). Prevents rapid-fire replacements. */
|
|
const NUDGE_THROTTLE_MS = 8_000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Toast helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Shows the nudge toast with interactive buttons. Returns a dismiss handle.
|
|
*
|
|
* On Android the toast includes an "Approve in signer" link that opens the
|
|
* signer via the `nostrsigner:` URI scheme (keeps the WebSocket alive), plus
|
|
* a Skip/Cancel button. On desktop it shows a description with a Skip button.
|
|
*/
|
|
function showNudgeToast(opts: {
|
|
kind: number | undefined;
|
|
opType: OpType;
|
|
isBunkerConnected: (() => boolean) | undefined;
|
|
onCancel: () => void;
|
|
}): { dismiss: () => void } {
|
|
const { kind, opType, isBunkerConnected, onCancel } = opts;
|
|
const android = isAndroid();
|
|
const relayOk = isBunkerConnected ? isBunkerConnected() : true;
|
|
const subject = labelForOp(kind, opType);
|
|
|
|
// Throttle: if a nudge was shown recently, return a no-op dismiss handle
|
|
// to avoid a storm of rapidly replacing toasts on unstable connections.
|
|
const now = Date.now();
|
|
if (now - lastNudgeShownAt < NUDGE_THROTTLE_MS) {
|
|
return { dismiss: () => {} };
|
|
}
|
|
lastNudgeShownAt = now;
|
|
|
|
let title: string;
|
|
let descriptionText: string;
|
|
|
|
if (!relayOk) {
|
|
title = 'Signer relay unreachable';
|
|
descriptionText = 'Check your connection and try again.';
|
|
} else if (android) {
|
|
title = `Approve ${subject}`;
|
|
descriptionText = 'Set to auto-approve for a smoother experience.';
|
|
} else {
|
|
title = `Approve ${subject}`;
|
|
descriptionText = 'Approve the request in your signer app.';
|
|
}
|
|
|
|
// We need to capture the dismiss function so the onCancel callback inside
|
|
// the component can dismiss the toast. We use a mutable ref pattern (object
|
|
// wrapper) so the closure captures the container rather than the value.
|
|
const dismissRef: { fn: (() => void) | undefined } = { fn: undefined };
|
|
|
|
const description = createElement(NudgeToastContent, {
|
|
description: descriptionText,
|
|
android,
|
|
relayOk,
|
|
onCancel: () => { dismissRef.fn?.(); onCancel(); },
|
|
});
|
|
|
|
// Use a long but finite duration so Radix swipe-to-dismiss works on mobile.
|
|
// The toast is dismissed programmatically on operation completion anyway.
|
|
const { dismiss } = toast({ title, description, duration: 120_000 });
|
|
dismissRef.fn = dismiss;
|
|
|
|
return { dismiss };
|
|
}
|
|
|
|
function showSuccessToast(opType: OpType): void {
|
|
const verb = opType === 'encrypt' ? 'Encryption' : opType === 'decrypt' ? 'Decryption' : 'Signing';
|
|
toast({ title: `${verb} approved`, duration: 3000, variant: 'success' });
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core: run a signer operation with nudge + retry logic
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface RunOpts {
|
|
kind: number | undefined;
|
|
opType: OpType;
|
|
isBunkerConnected: (() => boolean) | undefined;
|
|
}
|
|
|
|
/** Creates a deferred promise. Used to race against the actual signer op. */
|
|
function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
|
|
let resolve!: (v: T) => void;
|
|
const promise = new Promise<T>((r) => { resolve = r; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
/** Result of `runWithNudge` — value + whether the nudge toast was shown. */
|
|
interface RunResult<T> {
|
|
value: T;
|
|
nudgeFired: boolean;
|
|
}
|
|
|
|
/**
|
|
* Runs `op` with:
|
|
* - A nudge toast after NUDGE_DELAY_MS if still pending.
|
|
* - A hard timeout at HARD_TIMEOUT_MS.
|
|
*/
|
|
async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<RunResult<T>> {
|
|
const { kind, opType, isBunkerConnected } = opts;
|
|
|
|
// Tagged outcome type used to distinguish op results from control signals.
|
|
type Outcome =
|
|
| { tag: 'value'; value: T }
|
|
| { tag: 'error'; error: unknown }
|
|
| { tag: 'signal'; signal: Signal };
|
|
|
|
let nudgeFired = false;
|
|
|
|
// Signal channels — each resolves with a sentinel when its condition fires.
|
|
const cancelSignal = deferred<typeof CANCEL>();
|
|
const timeoutSignal = deferred<typeof TIMEOUT>();
|
|
|
|
// --- Nudge timer ---
|
|
let dismissNudge: (() => void) | undefined;
|
|
const nudgeTimer = setTimeout(() => {
|
|
nudgeFired = true;
|
|
const handle = showNudgeToast({
|
|
kind, opType, isBunkerConnected,
|
|
onCancel: () => cancelSignal.resolve(CANCEL),
|
|
});
|
|
dismissNudge = handle.dismiss;
|
|
}, NUDGE_DELAY_MS);
|
|
|
|
// --- Hard timeout ---
|
|
const hardTimer = setTimeout(() => timeoutSignal.resolve(TIMEOUT), HARD_TIMEOUT_MS);
|
|
|
|
function cleanup() {
|
|
clearTimeout(nudgeTimer);
|
|
clearTimeout(hardTimer);
|
|
dismissNudge?.();
|
|
}
|
|
|
|
const opOutcome: Promise<Outcome> = op().then(
|
|
(value): Outcome => ({ tag: 'value', value }),
|
|
(error): Outcome => ({ tag: 'error', error }),
|
|
);
|
|
|
|
const signalOutcome: Promise<Outcome> = Promise.race([
|
|
cancelSignal.promise,
|
|
timeoutSignal.promise,
|
|
]).then((signal): Outcome => ({ tag: 'signal', signal }));
|
|
|
|
const outcome = await Promise.race([opOutcome, signalOutcome]);
|
|
cleanup();
|
|
|
|
if (outcome.tag === 'value') {
|
|
if (nudgeFired) showSuccessToast(opType);
|
|
return { value: outcome.value, nudgeFired };
|
|
}
|
|
|
|
if (outcome.tag === 'error') {
|
|
throw outcome.error;
|
|
}
|
|
|
|
// outcome.tag === 'signal'
|
|
switch (outcome.signal) {
|
|
case CANCEL:
|
|
throw new Error('Signing cancelled by user');
|
|
|
|
case TIMEOUT:
|
|
throw new Error('Signer timed out');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Wraps a `NostrSigner` to add UX improvements for remote/external signers:
|
|
*
|
|
* - Shows a nudge toast after 4s if a signing or encryption op is still
|
|
* pending, so the user knows to check their signer app.
|
|
* - When a nip44 encrypt is immediately followed by a signEvent (e.g. saving
|
|
* encrypted settings), shows a phase-transition toast so the user knows to
|
|
* approve the second request.
|
|
*
|
|
* @param signer - The underlying NostrSigner to wrap.
|
|
* @param isBunkerConnected - Optional callback checked at nudge time; when it
|
|
* returns false the toast warns about a relay connectivity problem instead.
|
|
*/
|
|
export function signerWithNudge(
|
|
signer: NostrSigner,
|
|
isBunkerConnected?: () => boolean,
|
|
): NostrSigner {
|
|
/** Run an op and return just the value (discarding nudge metadata). */
|
|
function run<T>(op: () => Promise<T>, kind: number | undefined, opType: OpType): Promise<T> {
|
|
return runWithNudge(op, { kind, opType, isBunkerConnected }).then((r) => r.value);
|
|
}
|
|
|
|
const wrapped: NostrSigner = {
|
|
getPublicKey: () => run(() => signer.getPublicKey(), undefined, 'sign'),
|
|
|
|
signEvent: (event: NostrEvent) => {
|
|
return run(() => signer.signEvent(event), event.kind, 'sign');
|
|
},
|
|
};
|
|
|
|
if (signer.getRelays) {
|
|
const getRelays = signer.getRelays.bind(signer);
|
|
wrapped.getRelays = () => run(() => getRelays(), undefined, 'sign');
|
|
}
|
|
|
|
// Shared wrapper for nip04/nip44 encrypt and decrypt methods.
|
|
function wrapCrypto(crypto: {
|
|
encrypt: (pubkey: string, plaintext: string) => Promise<string>;
|
|
decrypt: (pubkey: string, ciphertext: string) => Promise<string>;
|
|
}) {
|
|
return {
|
|
encrypt: (pubkey: string, plaintext: string) =>
|
|
crypto.encrypt(pubkey, plaintext),
|
|
decrypt: (pubkey: string, ciphertext: string) =>
|
|
crypto.decrypt(pubkey, ciphertext),
|
|
};
|
|
}
|
|
|
|
if (signer.nip04) {
|
|
wrapped.nip04 = wrapCrypto(signer.nip04);
|
|
}
|
|
|
|
if (signer.nip44) {
|
|
wrapped.nip44 = wrapCrypto(signer.nip44);
|
|
}
|
|
|
|
if (hasBtcSigning(signer)) {
|
|
const btcWrapped = wrapped as BtcSigner;
|
|
const btcSigner = signer;
|
|
btcWrapped.signPsbt = (psbtHex: string) =>
|
|
run(() => btcSigner.signPsbt(psbtHex), undefined, 'sign');
|
|
}
|
|
|
|
return wrapped;
|
|
}
|