From 04b8b0a8b1aa169c940bb54629d75536c50bb3c5 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 21:13:58 +0700 Subject: [PATCH] Amber signing: add timeout + one-time setPackageName so hangs surface as errors (v1.0.74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: crew logo upload spins forever, and crew creation appears to do nothing. Both go through Amber signEvent. Login (getPublicKey) works because it uses Amber's silent content-resolver path, but signEvent must launch Amber's approval Activity and wait for the result — and if that result never comes back (buggy plugin build, user backs out, missing permission) the bridge promise never settles, so the UI hangs with no error. Changes: - withTimeout() wraps every Amber bridge call (sign/getPublicKey/nip04/nip44) with a 90s cap. A hung round-trip now rejects with a clear, actionable message instead of spinning forever, which also unblocks the CrewForm (isUploading/isPublishing finally resolve). - setPackageName is now called ONCE per session (cached promise) instead of before every op — repeated calls are needless bridge chatter and can wedge on some builds. NOTE (root cause, fixed on the build machine): package.json requests the GitHub `main` build of nostr-signer-capacitor-plugin, but package-lock.json still pins the old npm 0.0.5 (which the APK bundles). 0.0.5 is the version prone to the signEvent activity-result hang. Regenerate the lockfile with `npm install` so the `main` build is used — see chat for steps. Co-authored-by: shakespeare.diy --- package.json | 2 +- src/lib/capacitorAmberSigner.ts | 150 +++++++++++++++++++++++--------- 2 files changed, 110 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index c5ebe43..2800d58 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.0.73", + "version": "1.0.74", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/lib/capacitorAmberSigner.ts b/src/lib/capacitorAmberSigner.ts index 2944b57..bc9bd25 100644 --- a/src/lib/capacitorAmberSigner.ts +++ b/src/lib/capacitorAmberSigner.ts @@ -49,6 +49,42 @@ 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 +// bridge promise would otherwise hang FOREVER — which is exactly the "stuck +// loading" symptom on the crew logo upload and crew publish. This turns that +// silent hang into a clear, catchable error. +const AMBER_TIMEOUT_MS = 90_000; + +/** + * Race a bridge promise against a timeout so a hung Amber round-trip surfaces + * as an error instead of spinning forever. + */ +function withTimeout(promise: Promise, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error( + `Amber didn't respond to the ${label} request. Open Amber, approve the request, then try again. ` + + `If nothing appeared, make sure Amber is installed and SkateSpots is allowed to use it.`, + ), + ); + }, AMBER_TIMEOUT_MS); + + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + // Lazily build the native bridge proxy once. `registerPlugin` is a pure factory // (builds a proxy, touches no native APIs at call time), so it's safe on web too // — the proxy simply has no implementation there and calls reject. @@ -65,6 +101,25 @@ 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: Promise | null = null; +function ensurePackageName(plugin: NostrSignerNative): Promise { + if (!packageNameSet) { + packageNameSet = plugin + .setPackageName({ packageName: AMBER_PACKAGE }) + .catch((e) => { + // setPackageName can be a no-op that throws on some builds; log but + // don't block signing, and don't cache the failure. + console.warn('[CapacitorAmberSigner] setPackageName warning:', e); + packageNameSet = null; + }); + } + return packageNameSet; +} + /** * 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). @@ -74,12 +129,7 @@ async function getPlugin(): Promise { if (!plugin) { throw new Error('Amber signing is only available in the SkateSpots Android app.'); } - try { - await plugin.setPackageName({ packageName: AMBER_PACKAGE }); - } catch (e) { - // setPackageName can be a no-op that throws on some builds; log but continue. - console.warn('[CapacitorAmberSigner] setPackageName warning:', e); - } + await ensurePackageName(plugin); return plugin; } @@ -93,7 +143,10 @@ async function getPlugin(): Promise { export async function getAmberPublicKeyViaPlugin(): Promise { const plugin = await getPlugin(); - const result = await plugin.getPublicKey({ packageName: AMBER_PACKAGE }); + const result = await withTimeout( + plugin.getPublicKey({ packageName: AMBER_PACKAGE }), + 'login', + ); // JSObject from Capacitor — normalize to a plain object for reliable access. const plain = JSON.parse(JSON.stringify(result)) as Record; @@ -129,36 +182,45 @@ export class CapacitorAmberSigner implements NostrSigner { async signEvent(event: Omit): Promise { const plugin = await getPlugin(); - const { event: signedJson } = await plugin.signEvent({ - packageName: AMBER_PACKAGE, - eventJson: JSON.stringify(event), - eventId: makeId(), - npub: this.npub, - }); + const { event: signedJson } = await withTimeout( + plugin.signEvent({ + packageName: AMBER_PACKAGE, + eventJson: JSON.stringify(event), + eventId: makeId(), + npub: this.npub, + }), + 'signature', + ); return JSON.parse(signedJson) as NostrEvent; } readonly nip04 = { encrypt: async (pubkey: string, plaintext: string): Promise => { const plugin = await getPlugin(); - const { result } = await plugin.nip04Encrypt({ - packageName: AMBER_PACKAGE, - plainText: plaintext, - pubKey: pubkey, - npub: this.npub, - id: makeId(), - }); + 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 => { const plugin = await getPlugin(); - const { result } = await plugin.nip04Decrypt({ - packageName: AMBER_PACKAGE, - encryptedText: ciphertext, - pubKey: pubkey, - npub: this.npub, - id: makeId(), - }); + const { result } = await withTimeout( + plugin.nip04Decrypt({ + packageName: AMBER_PACKAGE, + encryptedText: ciphertext, + pubKey: pubkey, + npub: this.npub, + id: makeId(), + }), + 'decryption', + ); return result; }, }; @@ -166,24 +228,30 @@ export class CapacitorAmberSigner implements NostrSigner { readonly nip44 = { encrypt: async (pubkey: string, plaintext: string): Promise => { const plugin = await getPlugin(); - const { result } = await plugin.nip44Encrypt({ - packageName: AMBER_PACKAGE, - plainText: plaintext, - pubKey: pubkey, - npub: this.npub, - id: makeId(), - }); + 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 => { const plugin = await getPlugin(); - const { result } = await plugin.nip44Decrypt({ - packageName: AMBER_PACKAGE, - encryptedText: ciphertext, - pubKey: pubkey, - npub: this.npub, - id: makeId(), - }); + const { result } = await withTimeout( + plugin.nip44Decrypt({ + packageName: AMBER_PACKAGE, + encryptedText: ciphertext, + pubKey: pubkey, + npub: this.npub, + id: makeId(), + }), + 'decryption', + ); return result; }, };