fix(amber): call setPackageName before signing so the plugin opens Amber (1.2.9)

Verified against the plugin's Java source: signEvent/nip*Encrypt/Decrypt
IMMEDIATELY reject with "Signer package name not set" when no package is
configured — which is exactly what my earlier "omit packageName" change caused,
and that rejection surfaced as the endless spinner on delete/accept/zap.

With the package set, the plugin tries the content resolver, and when that
returns null (permission not remembered) it falls back to
startActivityForResult(nostrsigner:) — the SAME native call login uses that
reliably opens Amber. Restore native plugin signEvent/nip04/nip44 with
setPackageName + AMBER_PACKAGE, reconstruct signature-only responses, and remove
the unused JS-intent bridge. Bump to 1.2.9.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-04 21:44:27 +07:00
parent fea9ab3091
commit 50914b7b9b
4 changed files with 89 additions and 353 deletions

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.2.8",
"version": "1.2.9",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {

View file

@ -1,6 +1,5 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { resolveAmberIntent } from '@/lib/amberIntentBridge';
/**
* Forwards OS-level deep-link opens into the React Router navigation stack.
@ -45,16 +44,6 @@ export function DeepLinkHandler() {
AppPlugin.addListener('appUrlOpen', (event) => {
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);
// Pre-capture Amber callback event param into sessionStorage so
// AmberCallbackPage can find it after React Router renders the route.

View file

@ -1,314 +0,0 @@
/**
* 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)}`;
}
/**
* Launch an `intent:` URL so Amber opens.
*
* We navigate the WebView to the intent URL. Our custom native MainActivity
* (see android-templates/MainActivity.java) intercepts `intent:` /
* `nostrsigner:` navigations in shouldOverrideUrlLoading and hands them to the
* OS via startActivity() — a real native launch that opens Amber (the default
* Capacitor WebView silently drops these, which is why Amber never appeared).
*
* We assign to location.href in a microtask-safe way so the surrounding promise
* registration (in runIntent) has already stored the pending op before Android
* potentially pauses/replaces our WebView.
*/
async function openExternalUrl(url: string): Promise<void> {
// Defer one tick so the pending-op registry is populated before we navigate.
await Promise.resolve();
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,
});
}

View file

@ -30,13 +30,7 @@
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
import { Capacitor, registerPlugin } from '@capacitor/core';
import { nip19 } from 'nostr-tools';
import {
amberSignEventViaIntent,
amberNip04EncryptViaIntent,
amberNip04DecryptViaIntent,
amberNip44EncryptViaIntent,
amberNip44DecryptViaIntent,
} from '@/lib/amberIntentBridge';
import { reconstructSignedEvent } from '@/lib/amber';
const AMBER_PACKAGE = 'com.greenart7c3.nostrsigner';
@ -52,6 +46,10 @@ interface NostrSignerNative {
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
// 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
@ -188,33 +186,96 @@ export class CapacitorAmberSigner implements NostrSigner {
return this.pubkey;
}
/**
* Get the native plugin AND ensure the Amber package name is set on it.
*
* THE REAL FIX (verified against the plugin's Java source):
* The plugin's signEvent/nip*Encrypt/Decrypt IMMEDIATELY reject with
* "Signer package name not set. Call setPackageName first." if no package is
* configured — which is exactly what happened when we (wrongly) omitted it, and
* that rejection surfaced as the endless spinner.
*
* With the package set, the plugin tries Amber's Content Resolver first; when
* that returns null (permission not "remembered"), it FALLS BACK to
* startActivityForResult(nostrsigner:…) — the SAME native call login uses that
* reliably OPENS AMBER's approval screen. So the correct behaviour is: set the
* package, then call the native method. No JS-intent navigation needed.
*/
private async plugin(): Promise<NostrSignerNative> {
const plugin = getNativeSigner();
if (!plugin) {
throw new Error('Amber signing is only available in the SkateSpots Android app.');
}
// Idempotent; safe to call before every op. This is what unblocks signing.
await plugin.setPackageName({ packageName: AMBER_PACKAGE });
return plugin;
}
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
// CRITICAL FIX (GrapheneOS + others): drive Amber via the OS INTENT flow with
// a deep-link callback — NOT the capacitor plugin's Content-Resolver path.
//
// The plugin's signEvent() queries Amber's ContentProvider first. On
// GrapheneOS that background cross-app query STALLS and never returns, so
// Amber's approval screen never opens and signing hangs forever (the "stuck
// on Deleting…", "Accept invite spins", "Amber never pops up" bug). Login
// works only because getPublicKey falls through to an Intent. Here we use the
// Intent directly for signing too: it force-launches Amber's UI (the path
// that reliably works) and the result comes back via the app's custom-scheme
// deep link, resolved in-process by resolveAmberIntent() (see DeepLinkHandler).
return amberSignEventViaIntent(event, this.pubkey);
const plugin = await this.plugin();
const res = await withTimeout(
plugin.signEvent({
packageName: AMBER_PACKAGE,
eventJson: JSON.stringify(event),
eventId: makeId(),
npub: this.npub,
}),
'signature',
);
// Amber may return a full event JSON in `event`, or only a bare `signature`.
// Reconstruct defensively so signature-only responses (common for kind:5
// deletions and kind:1985 labels) still produce a valid, publishable event.
const raw =
res.event && res.event.trim()
? res.event
: (res.signature ?? '');
const signed = reconstructSignedEvent(raw, JSON.stringify(event), this.pubkey);
if (!signed || !signed.sig) {
throw new Error(
'Amber returned an incomplete signature. Open Amber, approve the request, then try again.',
);
}
return signed;
}
readonly nip04 = {
encrypt: (pubkey: string, plaintext: string): Promise<string> =>
amberNip04EncryptViaIntent(pubkey, plaintext, this.pubkey),
decrypt: (pubkey: string, ciphertext: string): Promise<string> =>
amberNip04DecryptViaIntent(pubkey, ciphertext, this.pubkey),
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
const plugin = await this.plugin();
const { result } = await withTimeout(
plugin.nip04Encrypt({ packageName: AMBER_PACKAGE, plainText: plaintext, pubKey: pubkey, npub: this.npub, id: makeId() }),
'encryption',
);
return result;
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
const plugin = await this.plugin();
const { result } = await withTimeout(
plugin.nip04Decrypt({ packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, id: makeId() }),
'decryption',
);
return result;
},
};
readonly nip44 = {
encrypt: (pubkey: string, plaintext: string): Promise<string> =>
amberNip44EncryptViaIntent(pubkey, plaintext, this.pubkey),
decrypt: (pubkey: string, ciphertext: string): Promise<string> =>
amberNip44DecryptViaIntent(pubkey, ciphertext, this.pubkey),
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
const plugin = await this.plugin();
const { result } = await withTimeout(
plugin.nip44Encrypt({ packageName: AMBER_PACKAGE, plainText: plaintext, pubKey: pubkey, npub: this.npub, id: makeId() }),
'encryption',
);
return result;
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
const plugin = await this.plugin();
const { result } = await withTimeout(
plugin.nip44Decrypt({ packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, id: makeId() }),
'decryption',
);
return result;
},
};
}