fix(amber): drive native signing/encryption via OS Intent + deep-link callback
The capacitor plugin's signEvent/encrypt query Amber's ContentProvider first, which stalls on GrapheneOS and never launches Amber's approval UI — causing delete, accept-invite, zaps, and every signing action to hang forever. Add amberIntentBridge.ts which drives Amber through the reliable intent:...scheme=nostrsigner flow (the same path login uses that actually opens Amber), returning a promise resolved in-process via the org.skatespots.app:// deep-link callback (DeepLinkHandler → resolveAmberIntent). CapacitorAmberSigner now routes signEvent/nip04/nip44 through this bridge instead of the stalling ContentResolver path. Fast 60s fail-safe with a clear message instead of an indefinite spinner. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
b6a37f8bbb
commit
0b50fbb91a
4 changed files with 364 additions and 155 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { resolveAmberIntent } from '@/lib/amberIntentBridge';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forwards OS-level deep-link opens into the React Router navigation stack.
|
* Forwards OS-level deep-link opens into the React Router navigation stack.
|
||||||
|
|
@ -44,6 +45,16 @@ export function DeepLinkHandler() {
|
||||||
|
|
||||||
AppPlugin.addListener('appUrlOpen', (event) => {
|
AppPlugin.addListener('appUrlOpen', (event) => {
|
||||||
try {
|
try {
|
||||||
|
// FIRST: try to resolve an in-process Amber Intent operation. The native
|
||||||
|
// signer (CapacitorAmberSigner) drives sign/encrypt/decrypt through the
|
||||||
|
// OS Intent flow and awaits the result via this deep-link callback. If
|
||||||
|
// this URL carries an `id=` that matches a pending op, resolve it HERE
|
||||||
|
// and stop — we must NOT navigate to /amber-callback (that path is the
|
||||||
|
// web/PWA reload-based flow and would double-process the result).
|
||||||
|
if (resolveAmberIntent(event.url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(event.url);
|
const url = new URL(event.url);
|
||||||
// Pre-capture Amber callback event param into sessionStorage so
|
// Pre-capture Amber callback event param into sessionStorage so
|
||||||
// AmberCallbackPage can find it after React Router renders the route.
|
// AmberCallbackPage can find it after React Router renders the route.
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,8 @@ export function useAcceptCrewInvite() {
|
||||||
const coord = parseAddrCoord(invite.crewCoord);
|
const coord = parseAddrCoord(invite.crewCoord);
|
||||||
if (!coord) throw new Error('Invalid crew reference.');
|
if (!coord) throw new Error('Invalid crew reference.');
|
||||||
|
|
||||||
|
// The ONE required action: publish the membership attestation. This is the
|
||||||
|
// single signature the UI waits on.
|
||||||
await confirm({
|
await confirm({
|
||||||
kind: coord.kind,
|
kind: coord.kind,
|
||||||
founderPubkey: coord.pubkey,
|
founderPubkey: coord.pubkey,
|
||||||
|
|
@ -199,7 +201,12 @@ export function useAcceptCrewInvite() {
|
||||||
visibility,
|
visibility,
|
||||||
});
|
});
|
||||||
|
|
||||||
await dismiss(invite.id);
|
// Dismiss is optimistic/local-first and best-effort for cross-device sync;
|
||||||
|
// fire it WITHOUT blocking accept so a second signer round-trip can never
|
||||||
|
// leave the Accept button stuck on "loading".
|
||||||
|
void dismiss(invite.id).catch(() => {
|
||||||
|
/* local dismissal already applied; sync is best-effort */
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
|
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
|
||||||
|
|
|
||||||
317
src/lib/amberIntentBridge.ts
Normal file
317
src/lib/amberIntentBridge.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
/**
|
||||||
|
* amberIntentBridge
|
||||||
|
* =================
|
||||||
|
*
|
||||||
|
* Reliable Amber (NIP-55) signing/encryption for the NATIVE Android app
|
||||||
|
* (Capacitor / ZapStore APK), using the OS **Intent** flow with a deep-link
|
||||||
|
* callback — NOT the capacitor plugin's Content-Resolver path.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
* ---------------
|
||||||
|
* The `nostr-signer-capacitor-plugin` `signEvent()` tries Amber's *Content
|
||||||
|
* Resolver* FIRST (a background, no-UI query). Per NIP-55 that only works for
|
||||||
|
* permissions the user chose to "remember", and it returns `null` otherwise so
|
||||||
|
* the client can fall back to an Intent. On GrapheneOS that cross-app
|
||||||
|
* ContentResolver query does not return cleanly — it STALLS — so Amber's
|
||||||
|
* approval screen never opens and the request hangs forever ("stuck on
|
||||||
|
* Deleting…", "Accept invite spins", Amber never pops up).
|
||||||
|
*
|
||||||
|
* Login works because it uses `getPublicKey` which, without remembered
|
||||||
|
* permissions, falls through to `startActivityForResult(nostrsigner:)` and
|
||||||
|
* launches Amber's UI. We mirror that reliable behaviour for EVERY signing and
|
||||||
|
* encryption op by driving the Intent directly:
|
||||||
|
*
|
||||||
|
* 1. Build an `intent:<payload>#Intent;scheme=nostrsigner;...;end` URL with a
|
||||||
|
* `callbackUrl` pointing back into the app via its custom scheme
|
||||||
|
* (`org.skatespots.app://amber-callback?id=<opId>&event=`).
|
||||||
|
* 2. Open it (Capacitor App.openUrl → falls back to window.location.href).
|
||||||
|
* Android launches Amber's approval UI — the path that WORKS on GrapheneOS.
|
||||||
|
* 3. Return a Promise kept pending in an in-memory registry keyed by `opId`.
|
||||||
|
* 4. When the user approves, Amber appends the result and reopens the app via
|
||||||
|
* the deep link. `resolveAmberIntent(url)` (called from DeepLinkHandler)
|
||||||
|
* matches `opId` and resolves the pending Promise IN PROCESS — no page
|
||||||
|
* reload, so the original mutation (delete, accept invite, zap, …)
|
||||||
|
* completes normally.
|
||||||
|
*
|
||||||
|
* This module has NO hard dependency on @capacitor/app; it uses the global
|
||||||
|
* window.Capacitor bridge so the web/CI build is unaffected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { reconstructSignedEvent } from '@/lib/amber';
|
||||||
|
|
||||||
|
/** Amber operation types we drive through the Intent bridge. */
|
||||||
|
export type AmberIntentType =
|
||||||
|
| 'sign_event'
|
||||||
|
| 'nip04_encrypt'
|
||||||
|
| 'nip04_decrypt'
|
||||||
|
| 'nip44_encrypt'
|
||||||
|
| 'nip44_decrypt';
|
||||||
|
|
||||||
|
interface PendingOp {
|
||||||
|
type: AmberIntentType;
|
||||||
|
/** Resolve with the RAW string result from Amber (sig / event JSON / ciphertext / plaintext). */
|
||||||
|
resolve: (rawResult: string) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
/** Original unsigned event JSON — needed to reconstruct a full signed event. */
|
||||||
|
eventJson?: string;
|
||||||
|
/** The signer's own pubkey (hex) — used when reconstructing. */
|
||||||
|
currentUser: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory registry of operations awaiting an Amber callback. Keyed by a
|
||||||
|
* unique per-request id embedded in the callbackUrl so the deep-link handler
|
||||||
|
* can resolve exactly the right promise even if several are outstanding.
|
||||||
|
*/
|
||||||
|
const pending = new Map<string, PendingOp>();
|
||||||
|
|
||||||
|
/** Native app's custom URI scheme (matches Capacitor appId). */
|
||||||
|
const CALLBACK_SCHEME = 'org.skatespots.app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait for Amber to come back before rejecting. Interactive taps
|
||||||
|
* (delete, accept, zap) should fail fast with a clear message rather than hang.
|
||||||
|
* The user has to switch to Amber, approve, and switch back — 60s is generous.
|
||||||
|
*/
|
||||||
|
const INTENT_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
function makeOpId(): string {
|
||||||
|
return `si_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a URL via the native runtime, falling back to a plain navigation. */
|
||||||
|
async function openExternalUrl(url: string): Promise<void> {
|
||||||
|
const cap = (window as unknown as {
|
||||||
|
Capacitor?: {
|
||||||
|
isNativePlatform?: () => boolean;
|
||||||
|
Plugins?: { App?: { openUrl?: (o: { url: string }) => Promise<unknown> } };
|
||||||
|
};
|
||||||
|
}).Capacitor;
|
||||||
|
|
||||||
|
const openUrl = cap?.Plugins?.App?.openUrl;
|
||||||
|
if (openUrl) {
|
||||||
|
try {
|
||||||
|
await openUrl({ url });
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// fall through to location navigation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: assign to location. On Android WebView this still fires the intent.
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Android `intent:` URL for Amber.
|
||||||
|
*
|
||||||
|
* Format (per Amber README / NIP-55 web section):
|
||||||
|
* intent:<payload>#Intent;scheme=nostrsigner;S.k=v;...;end
|
||||||
|
*
|
||||||
|
* - `payload` is the request body (event JSON / plaintext / ciphertext),
|
||||||
|
* URI-encoded and placed BEFORE the `#`.
|
||||||
|
* - Extra params are `S.<key>=<value>` pairs separated by `;`.
|
||||||
|
* - `callbackUrl` is embedded RAW (no encoding) and MUST NOT contain a `#`.
|
||||||
|
*/
|
||||||
|
function buildIntentUrl(payload: string, params: Record<string, string>): string {
|
||||||
|
const extras = Object.entries(params)
|
||||||
|
.map(([k, v]) => `S.${k}=${v}`)
|
||||||
|
.join(';');
|
||||||
|
return `intent:${payload}#Intent;scheme=nostrsigner;${extras};end`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch Amber for a single operation and return a promise that resolves with
|
||||||
|
* the RAW string result once the app is reopened via the deep-link callback.
|
||||||
|
*/
|
||||||
|
function runIntent(args: {
|
||||||
|
type: AmberIntentType;
|
||||||
|
/** Request body sent to Amber (already the plaintext/ciphertext/event JSON). */
|
||||||
|
payload: string;
|
||||||
|
/** Amber return type: 'event' for sign, 'signature' for encrypt/decrypt. */
|
||||||
|
returnType: 'event' | 'signature';
|
||||||
|
/** Counterparty pubkey for encryption ops. */
|
||||||
|
pubKey?: string;
|
||||||
|
/** Original event JSON (sign only) for reconstruction on the way back. */
|
||||||
|
eventJson?: string;
|
||||||
|
currentUser: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const opId = makeOpId();
|
||||||
|
|
||||||
|
// Callback carries the opId so the deep-link handler resolves the right
|
||||||
|
// promise, plus the trailing `event=` where Amber appends the result.
|
||||||
|
const callbackUrl = `${CALLBACK_SCHEME}://amber-callback?id=${opId}&event=`;
|
||||||
|
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
type: args.type,
|
||||||
|
compressionType: 'none',
|
||||||
|
returnType: args.returnType,
|
||||||
|
callbackUrl,
|
||||||
|
current_user: args.currentUser,
|
||||||
|
};
|
||||||
|
if (args.pubKey) params.pubKey = args.pubKey;
|
||||||
|
|
||||||
|
const intentUrl = buildIntentUrl(encodeURIComponent(args.payload), params);
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(opId);
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
"Amber didn't respond. Open Amber, approve the request, then try again. " +
|
||||||
|
'If nothing appeared, make sure Amber is installed and allowed to run.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, INTENT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
pending.set(opId, {
|
||||||
|
type: args.type,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timer,
|
||||||
|
eventJson: args.eventJson,
|
||||||
|
currentUser: args.currentUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire the intent AFTER registering so a fast callback can't race us.
|
||||||
|
openExternalUrl(intentUrl).catch((err) => {
|
||||||
|
const op = pending.get(opId);
|
||||||
|
if (op) {
|
||||||
|
clearTimeout(op.timer);
|
||||||
|
pending.delete(opId);
|
||||||
|
}
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a pending Amber operation from a deep-link callback URL.
|
||||||
|
*
|
||||||
|
* Called by DeepLinkHandler on every `appUrlOpen`. Returns `true` if the URL
|
||||||
|
* was an Amber intent-bridge callback that matched a pending op (so the caller
|
||||||
|
* can stop further routing), `false` otherwise.
|
||||||
|
*/
|
||||||
|
export function resolveAmberIntent(rawUrl: string): boolean {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opId = url.searchParams.get('id');
|
||||||
|
if (!opId) return false;
|
||||||
|
|
||||||
|
const op = pending.get(opId);
|
||||||
|
if (!op) {
|
||||||
|
// No matching op (already resolved / timed out). Still consume it so the
|
||||||
|
// generic navigation handler doesn't try to route to /amber-callback.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(op.timer);
|
||||||
|
pending.delete(opId);
|
||||||
|
|
||||||
|
const result = url.searchParams.get('event') ?? '';
|
||||||
|
if (!result) {
|
||||||
|
op.reject(
|
||||||
|
new Error('Amber returned no result. The request may have been rejected.'),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
op.resolve(result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if there are outstanding intent operations awaiting a callback. */
|
||||||
|
export function hasPendingAmberIntent(): boolean {
|
||||||
|
return pending.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── High-level operations used by the native signer ─────────────────────────
|
||||||
|
|
||||||
|
/** Sign an event via the Amber Intent flow; resolves to a full NostrEvent. */
|
||||||
|
export async function amberSignEventViaIntent(
|
||||||
|
event: Omit<NostrEvent, 'id' | 'sig'>,
|
||||||
|
currentUser: string,
|
||||||
|
): Promise<NostrEvent> {
|
||||||
|
const eventJson = JSON.stringify(event);
|
||||||
|
const raw = await runIntent({
|
||||||
|
type: 'sign_event',
|
||||||
|
payload: eventJson,
|
||||||
|
returnType: 'event',
|
||||||
|
eventJson,
|
||||||
|
currentUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signed = reconstructSignedEvent(raw, eventJson, currentUser);
|
||||||
|
if (!signed || !signed.sig) {
|
||||||
|
throw new Error(
|
||||||
|
'Amber returned an incomplete signature. Open Amber, approve the request, then try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return signed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NIP-04 encrypt via the Amber Intent flow. */
|
||||||
|
export function amberNip04EncryptViaIntent(
|
||||||
|
peerPubkey: string,
|
||||||
|
plaintext: string,
|
||||||
|
currentUser: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return runIntent({
|
||||||
|
type: 'nip04_encrypt',
|
||||||
|
payload: plaintext,
|
||||||
|
returnType: 'signature',
|
||||||
|
pubKey: peerPubkey,
|
||||||
|
currentUser,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NIP-04 decrypt via the Amber Intent flow. */
|
||||||
|
export function amberNip04DecryptViaIntent(
|
||||||
|
peerPubkey: string,
|
||||||
|
ciphertext: string,
|
||||||
|
currentUser: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return runIntent({
|
||||||
|
type: 'nip04_decrypt',
|
||||||
|
payload: ciphertext,
|
||||||
|
returnType: 'signature',
|
||||||
|
pubKey: peerPubkey,
|
||||||
|
currentUser,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NIP-44 encrypt via the Amber Intent flow. */
|
||||||
|
export function amberNip44EncryptViaIntent(
|
||||||
|
peerPubkey: string,
|
||||||
|
plaintext: string,
|
||||||
|
currentUser: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return runIntent({
|
||||||
|
type: 'nip44_encrypt',
|
||||||
|
payload: plaintext,
|
||||||
|
returnType: 'signature',
|
||||||
|
pubKey: peerPubkey,
|
||||||
|
currentUser,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NIP-44 decrypt via the Amber Intent flow. */
|
||||||
|
export function amberNip44DecryptViaIntent(
|
||||||
|
peerPubkey: string,
|
||||||
|
ciphertext: string,
|
||||||
|
currentUser: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return runIntent({
|
||||||
|
type: 'nip44_decrypt',
|
||||||
|
payload: ciphertext,
|
||||||
|
returnType: 'signature',
|
||||||
|
pubKey: peerPubkey,
|
||||||
|
currentUser,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,14 @@
|
||||||
|
|
||||||
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
|
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
|
||||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
import { nip19, getEventHash } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import {
|
||||||
|
amberSignEventViaIntent,
|
||||||
|
amberNip04EncryptViaIntent,
|
||||||
|
amberNip04DecryptViaIntent,
|
||||||
|
amberNip44EncryptViaIntent,
|
||||||
|
amberNip44DecryptViaIntent,
|
||||||
|
} from '@/lib/amberIntentBridge';
|
||||||
|
|
||||||
const AMBER_PACKAGE = 'com.greenart7c3.nostrsigner';
|
const AMBER_PACKAGE = 'com.greenart7c3.nostrsigner';
|
||||||
|
|
||||||
|
|
@ -45,10 +52,6 @@ interface NostrSignerNative {
|
||||||
nip44Decrypt(options: { packageName?: string; encryptedText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>;
|
nip44Decrypt(options: { packageName?: string; encryptedText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeId(): string {
|
|
||||||
return `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// How long to wait for Amber to return before giving up. Amber is a separate
|
// How long to wait for Amber to return before giving up. Amber is a separate
|
||||||
// app: the user has to switch to it, approve, and switch back. If the native
|
// app: the user has to switch to it, approve, and switch back. If the native
|
||||||
// activity-result never fires (user backs out, Amber not granted, etc.) the
|
// activity-result never fires (user backs out, Amber not granted, etc.) the
|
||||||
|
|
@ -186,162 +189,33 @@ export class CapacitorAmberSigner implements NostrSigner {
|
||||||
}
|
}
|
||||||
|
|
||||||
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
|
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
|
||||||
// CRITICAL (GrapheneOS + others): do NOT pass packageName here. When a
|
// CRITICAL FIX (GrapheneOS + others): drive Amber via the OS INTENT flow with
|
||||||
// packageName is provided, the native plugin first attempts Amber's
|
// a deep-link callback — NOT the capacitor plugin's Content-Resolver path.
|
||||||
// ContentProvider (a background, no-UI query) which stalls on GrapheneOS and
|
//
|
||||||
// NEVER launches Amber's approval screen — so the user sees no prompt and the
|
// The plugin's signEvent() queries Amber's ContentProvider first. On
|
||||||
// request just times out ("stuck on Deleting…", Amber never popped up). This
|
// GrapheneOS that background cross-app query STALLS and never returns, so
|
||||||
// is the exact same trap the login flow (getAmberPublicKeyViaPlugin) avoids
|
// Amber's approval screen never opens and signing hangs forever (the "stuck
|
||||||
// by omitting packageName so the plugin goes straight to
|
// on Deleting…", "Accept invite spins", "Amber never pops up" bug). Login
|
||||||
// startActivityForResult(nostrsigner:) which shows Amber's UI. We mirror that
|
// works only because getPublicKey falls through to an Intent. Here we use the
|
||||||
// here: use the raw native signer with NO packageName.
|
// Intent directly for signing too: it force-launches Amber's UI (the path
|
||||||
const plugin = getNativeSigner();
|
// that reliably works) and the result comes back via the app's custom-scheme
|
||||||
if (!plugin) {
|
// deep link, resolved in-process by resolveAmberIntent() (see DeepLinkHandler).
|
||||||
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
return amberSignEventViaIntent(event, this.pubkey);
|
||||||
}
|
|
||||||
const res = await withTimeout(
|
|
||||||
plugin.signEvent({
|
|
||||||
eventJson: JSON.stringify(event),
|
|
||||||
eventId: makeId(),
|
|
||||||
npub: this.npub,
|
|
||||||
}),
|
|
||||||
'signature',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Amber's response shape varies across builds: some return the full signed
|
|
||||||
// event JSON in `event`, others return only `signature` (expecting the caller
|
|
||||||
// to attach it to the original event). If we blindly `JSON.parse` an empty
|
|
||||||
// `event`, or return an event with no `sig`, the resulting object silently
|
|
||||||
// publishes as a malformed event and stalls the relay forever — the exact
|
|
||||||
// "publish hangs" symptom. Handle every shape defensively and always fail
|
|
||||||
// loud rather than emitting a bad event.
|
|
||||||
let signed: Partial<NostrEvent> | null = null;
|
|
||||||
|
|
||||||
if (res.event && res.event.trim()) {
|
|
||||||
try {
|
|
||||||
signed = JSON.parse(res.event) as Partial<NostrEvent>;
|
|
||||||
} catch {
|
|
||||||
signed = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to reconstructing the event from a bare signature.
|
|
||||||
if ((!signed || !signed.sig) && res.signature) {
|
|
||||||
signed = { ...(signed ?? {}), sig: res.signature };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signed || !signed.sig) {
|
|
||||||
throw new Error(
|
|
||||||
'Amber returned an incomplete signature. Open Amber, approve the request, then try again.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge Amber's response over the original event so required fields are
|
|
||||||
// never lost, then verify the essentials are present before returning.
|
|
||||||
const pubkey = signed.pubkey ?? this.pubkey;
|
|
||||||
const base = {
|
|
||||||
kind: signed.kind ?? event.kind,
|
|
||||||
content: signed.content ?? event.content,
|
|
||||||
tags: signed.tags ?? event.tags,
|
|
||||||
created_at: signed.created_at ?? event.created_at,
|
|
||||||
pubkey,
|
|
||||||
};
|
|
||||||
|
|
||||||
// CRITICAL: Amber (NIP-55) commonly returns ONLY the `signature` for events
|
|
||||||
// like kind:5 deletions and kind:1985 labels — no `id` and no full event
|
|
||||||
// JSON. Previously we left `id: ''`, which failed the malformed-event check
|
|
||||||
// below (or emitted a bad event that stalled the relay forever). The event
|
|
||||||
// id is a deterministic NIP-01 hash of these fields, so we compute it
|
|
||||||
// ourselves instead of depending on Amber to echo it back. This makes
|
|
||||||
// signature-only responses work for EVERY kind — including admin takedowns.
|
|
||||||
const id =
|
|
||||||
signed.id && signed.id.length === 64 ? signed.id : getEventHash(base);
|
|
||||||
|
|
||||||
const merged: NostrEvent = {
|
|
||||||
...base,
|
|
||||||
id,
|
|
||||||
sig: signed.sig,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!merged.id || !merged.sig || !merged.pubkey) {
|
|
||||||
throw new Error(
|
|
||||||
'Amber returned a malformed event. Please retry, or restart Amber if it persists.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly nip04 = {
|
readonly nip04 = {
|
||||||
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
encrypt: (pubkey: string, plaintext: string): Promise<string> =>
|
||||||
const plugin = this.rawPlugin();
|
amberNip04EncryptViaIntent(pubkey, plaintext, this.pubkey),
|
||||||
const { result } = await withTimeout(
|
decrypt: (pubkey: string, ciphertext: string): Promise<string> =>
|
||||||
plugin.nip04Encrypt({
|
amberNip04DecryptViaIntent(pubkey, ciphertext, this.pubkey),
|
||||||
plainText: plaintext,
|
|
||||||
pubKey: pubkey,
|
|
||||||
npub: this.npub,
|
|
||||||
id: makeId(),
|
|
||||||
}),
|
|
||||||
'encryption',
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
|
||||||
const plugin = this.rawPlugin();
|
|
||||||
const { result } = await withTimeout(
|
|
||||||
plugin.nip04Decrypt({
|
|
||||||
encryptedText: ciphertext,
|
|
||||||
pubKey: pubkey,
|
|
||||||
npub: this.npub,
|
|
||||||
id: makeId(),
|
|
||||||
}),
|
|
||||||
'decryption',
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly nip44 = {
|
readonly nip44 = {
|
||||||
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
encrypt: (pubkey: string, plaintext: string): Promise<string> =>
|
||||||
const plugin = this.rawPlugin();
|
amberNip44EncryptViaIntent(pubkey, plaintext, this.pubkey),
|
||||||
const { result } = await withTimeout(
|
decrypt: (pubkey: string, ciphertext: string): Promise<string> =>
|
||||||
plugin.nip44Encrypt({
|
amberNip44DecryptViaIntent(pubkey, ciphertext, this.pubkey),
|
||||||
plainText: plaintext,
|
|
||||||
pubKey: pubkey,
|
|
||||||
npub: this.npub,
|
|
||||||
id: makeId(),
|
|
||||||
}),
|
|
||||||
'encryption',
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
|
||||||
const plugin = this.rawPlugin();
|
|
||||||
const { result } = await withTimeout(
|
|
||||||
plugin.nip44Decrypt({
|
|
||||||
encryptedText: ciphertext,
|
|
||||||
pubKey: pubkey,
|
|
||||||
npub: this.npub,
|
|
||||||
id: makeId(),
|
|
||||||
}),
|
|
||||||
'decryption',
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw native signer WITHOUT packageName — same rationale as signEvent: passing
|
|
||||||
* a packageName triggers Amber's stalling ContentProvider path on GrapheneOS
|
|
||||||
* so the approval UI never appears. Throws clearly if not on native.
|
|
||||||
*/
|
|
||||||
private rawPlugin(): NostrSignerNative {
|
|
||||||
const plugin = getNativeSigner();
|
|
||||||
if (!plugin) {
|
|
||||||
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
|
||||||
}
|
|
||||||
return plugin;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue