diff --git a/src/lib/capacitorAmberSigner.ts b/src/lib/capacitorAmberSigner.ts index bccd699..22be76c 100644 --- a/src/lib/capacitorAmberSigner.ts +++ b/src/lib/capacitorAmberSigner.ts @@ -101,43 +101,13 @@ function getNativeSigner(): NostrSignerNative | null { return nativePlugin; } -// Set the Amber package name exactly ONCE per app session. Calling it before -// every sign/encrypt op is unnecessary bridge chatter and, on some builds, a -// repeated setPackageName can itself wedge. We cache the promise so concurrent -// callers await the same setup. -let packageNameSet = false; -async function ensurePackageName(plugin: NostrSignerNative): Promise { - if (packageNameSet) return; - // CRITICAL: setPackageName MUST NOT be able to wedge the login flow. On some - // devices (GrapheneOS in particular) this bridge call can hang and never - // resolve — and because getPlugin() awaited it, getPublicKey() was never even - // reached, so its 90s timeout never started → eternal "Waiting for Amber". - // We race it against a short timeout and simply continue regardless: Amber's - // package is also passed explicitly on every getPublicKey/sign/encrypt call, - // so setPackageName is a nice-to-have, not a hard requirement. - try { - await Promise.race([ - plugin.setPackageName({ packageName: AMBER_PACKAGE }), - new Promise((resolve) => setTimeout(resolve, 3000)), - ]); - } catch (e) { - console.warn('[CapacitorAmberSigner] setPackageName warning (continuing):', e); - } - packageNameSet = true; -} - -/** - * Get the native signer, ensuring the Amber package name is set first. - * Throws a clear error if not on a native platform (caller can surface it). - */ -async function getPlugin(): Promise { - const plugin = getNativeSigner(); - if (!plugin) { - throw new Error('Amber signing is only available in the SkateSpots Android app.'); - } - await ensurePackageName(plugin); - return plugin; -} +// NOTE: We deliberately do NOT call setPackageName / pass packageName on +// sign/encrypt/decrypt calls. Providing a packageName makes the native plugin +// try Amber's ContentProvider (a background, no-UI query) first, which stalls on +// GrapheneOS (and some other ROMs) so Amber's approval screen never appears and +// the request just times out. Omitting it forces the plugin straight to +// startActivityForResult(nostrsigner:) which launches Amber's UI — the reliable +// path the login flow already uses. /** * Login: get public key from Amber. @@ -201,10 +171,21 @@ export class CapacitorAmberSigner implements NostrSigner { } async signEvent(event: Omit): Promise { - const plugin = await getPlugin(); + // CRITICAL (GrapheneOS + others): do NOT pass packageName here. When a + // packageName is provided, the native plugin first attempts Amber's + // 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 + // request just times out ("stuck on Deleting…", Amber never popped up). This + // is the exact same trap the login flow (getAmberPublicKeyViaPlugin) avoids + // by omitting packageName so the plugin goes straight to + // startActivityForResult(nostrsigner:) which shows Amber's UI. We mirror that + // here: use the raw native signer with NO packageName. + const plugin = getNativeSigner(); + if (!plugin) { + throw new Error('Amber signing is only available in the SkateSpots Android app.'); + } const res = await withTimeout( plugin.signEvent({ - packageName: AMBER_PACKAGE, eventJson: JSON.stringify(event), eventId: makeId(), npub: this.npub, @@ -278,10 +259,9 @@ export class CapacitorAmberSigner implements NostrSigner { readonly nip04 = { encrypt: async (pubkey: string, plaintext: string): Promise => { - const plugin = await getPlugin(); + const plugin = this.rawPlugin(); const { result } = await withTimeout( plugin.nip04Encrypt({ - packageName: AMBER_PACKAGE, plainText: plaintext, pubKey: pubkey, npub: this.npub, @@ -292,10 +272,9 @@ export class CapacitorAmberSigner implements NostrSigner { return result; }, decrypt: async (pubkey: string, ciphertext: string): Promise => { - const plugin = await getPlugin(); + const plugin = this.rawPlugin(); const { result } = await withTimeout( plugin.nip04Decrypt({ - packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, @@ -309,10 +288,9 @@ export class CapacitorAmberSigner implements NostrSigner { readonly nip44 = { encrypt: async (pubkey: string, plaintext: string): Promise => { - const plugin = await getPlugin(); + const plugin = this.rawPlugin(); const { result } = await withTimeout( plugin.nip44Encrypt({ - packageName: AMBER_PACKAGE, plainText: plaintext, pubKey: pubkey, npub: this.npub, @@ -323,10 +301,9 @@ export class CapacitorAmberSigner implements NostrSigner { return result; }, decrypt: async (pubkey: string, ciphertext: string): Promise => { - const plugin = await getPlugin(); + const plugin = this.rawPlugin(); const { result } = await withTimeout( plugin.nip44Decrypt({ - packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, @@ -337,6 +314,19 @@ export class CapacitorAmberSigner implements NostrSigner { 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; + } } /**