SkateSpots/src/lib/capacitorAmberSigner.ts
shakespeare.diy 04b8b0a8b1 Amber signing: add timeout + one-time setPackageName so hangs surface as errors (v1.0.74)
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 <assistant@shakespeare.diy>
2026-07-24 21:13:58 +07:00

272 lines
9.6 KiB
TypeScript

/**
* CapacitorAmberSigner
*
* Talks to the native Amber signer (nostr-signer-capacitor-plugin) over the
* Capacitor bridge.
*
* WHY WE DON'T `import('nostr-signer-capacitor-plugin')` ANYMORE
* -------------------------------------------------------------
* The package is marked `external` in vite.config.ts and the native build uses
* `inlineDynamicImports`, so a runtime `import('nostr-signer-capacitor-plugin')`
* stays in the bundle as a BARE specifier the Android WebView cannot resolve.
* That import threw, `getPlugin()` returned null, and every signing operation
* (including the Blossom upload auth event) failed with
* "Capacitor Amber plugin not available".
*
* THE FIX: the plugin's JS layer is just a thin wrapper around
* `registerPlugin('NostrSignerPlugin')` from @capacitor/core. @capacitor/core
* IS bundled, so we call `registerPlugin` ourselves to get the native bridge
* proxy directly and reimplement the (tiny, well-known) wrapper logic here.
* This is the same runtime-plugin pattern that fixed the camera + white-screen.
*
* API shape reference: coracle-social/nostr-signer-capacitor-plugin#main
* native.signEvent({ packageName, eventJson, eventId, npub })
* → { signature, id, event }
* native.getPublicKey({ packageName, permissions? }) → { npub, package }
* native.nipXXEncrypt/Decrypt({ packageName, plainText|encryptedText, pubKey, npub, id })
* → { result, id }
*/
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
import { Capacitor, registerPlugin } from '@capacitor/core';
import { nip19 } from 'nostr-tools';
const AMBER_PACKAGE = 'com.greenart7c3.nostrsigner';
/** Raw native bridge interface (Android). All params are options objects. */
interface NostrSignerNative {
setPackageName(options: { packageName: string }): Promise<void>;
isExternalSignerInstalled(options?: { packageName?: string }): Promise<{ installed: boolean }>;
getPublicKey(options?: { packageName?: string; permissions?: string }): Promise<{ npub: string; package?: string }>;
signEvent(options: { packageName?: string; eventJson: string; eventId: string; npub: string }): Promise<{ signature?: string; id?: string; event: string }>;
nip04Encrypt(options: { packageName?: string; plainText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>;
nip04Decrypt(options: { packageName?: string; encryptedText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>;
nip44Encrypt(options: { packageName?: string; plainText: 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
// 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<T>(promise: Promise<T>, label: string): Promise<T> {
return new Promise<T>((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.
let nativePlugin: NostrSignerNative | null = null;
function getNativeSigner(): NostrSignerNative | null {
try {
if (!Capacitor?.isNativePlatform?.()) return null;
} catch {
return null;
}
if (!nativePlugin) {
nativePlugin = registerPlugin<NostrSignerNative>('NostrSignerPlugin');
}
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<void> | null = null;
function ensurePackageName(plugin: NostrSignerNative): Promise<void> {
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).
*/
async function getPlugin(): Promise<NostrSignerNative> {
const plugin = getNativeSigner();
if (!plugin) {
throw new Error('Amber signing is only available in the SkateSpots Android app.');
}
await ensurePackageName(plugin);
return plugin;
}
/**
* Login: get public key from Amber.
*
* The Java bridge calls npubToHex() before returning, so the "npub" field is
* actually a hex pubkey string despite the misleading name (older builds). We
* handle both hex and real npub just in case.
*/
export async function getAmberPublicKeyViaPlugin(): Promise<string> {
const plugin = await getPlugin();
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<string, string>;
const value = plain.npub ?? plain.pubkey ?? plain.result ?? '';
if (!value) throw new Error('Amber returned empty key');
if (/^[0-9a-f]{64}$/i.test(value)) {
return value.toLowerCase();
}
if (value.startsWith('npub1')) {
const decoded = nip19.decode(value);
if (decoded.type !== 'npub') throw new Error('Unexpected key format from Amber');
return decoded.data as string;
}
throw new Error(`Unexpected Amber response: "${value.slice(0, 30)}"`);
}
export class CapacitorAmberSigner implements NostrSigner {
readonly pubkey: string;
private readonly npub: string;
constructor(pubkeyHex: string) {
this.pubkey = pubkeyHex;
this.npub = nip19.npubEncode(pubkeyHex);
}
async getPublicKey(): Promise<string> {
return this.pubkey;
}
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
const plugin = await getPlugin();
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<string> => {
const plugin = await getPlugin();
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 getPlugin();
const { result } = await withTimeout(
plugin.nip04Decrypt({
packageName: AMBER_PACKAGE,
encryptedText: ciphertext,
pubKey: pubkey,
npub: this.npub,
id: makeId(),
}),
'decryption',
);
return result;
},
};
readonly nip44 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
const plugin = await getPlugin();
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 getPlugin();
const { result } = await withTimeout(
plugin.nip44Decrypt({
packageName: AMBER_PACKAGE,
encryptedText: ciphertext,
pubKey: pubkey,
npub: this.npub,
id: makeId(),
}),
'decryption',
);
return result;
},
};
}
/**
* Is an external signer (Amber) installed on this device?
*/
export async function isAmberInstalled(): Promise<boolean> {
const plugin = getNativeSigner();
if (!plugin) return false;
try {
const { installed } = await plugin.isExternalSignerInstalled({ packageName: AMBER_PACKAGE });
return installed;
} catch {
return false;
}
}