Fix "Capacitor Amber plugin not available" on upload/signing (v1.0.71)
Root cause (same class as the camera bug): capacitorAmberSigner.ts used
`await import('nostr-signer-capacitor-plugin')`, but that package is marked
external in vite.config.ts and the native build uses inlineDynamicImports, so
the bare specifier survives in the APK bundle and the Android WebView can't
resolve it at runtime. The import threw, getPlugin() returned null, and every
Amber signing op — including the Blossom upload auth event that a Crew logo
upload needs — failed with "Capacitor Amber plugin not available".
Fix: access the native signer via Capacitor's registerPlugin('NostrSignerPlugin')
from the bundled @capacitor/core, and reimplement the plugin's thin JS wrapper
logic directly against the native bridge (options-object API per the plugin's
main source). This removes the broken dynamic import entirely and is version-
independent as long as the native plugin is registered. Covers both native
Amber login (getAmberPublicKeyViaPlugin) and all signing/encryption paths.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
939a101b38
commit
def67d4df2
2 changed files with 108 additions and 100 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "skate-spots",
|
||||
"private": true,
|
||||
"version": "1.0.70",
|
||||
"version": "1.0.71",
|
||||
"type": "module",
|
||||
"homepage": "https://skate-spots.com",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,78 +1,110 @@
|
|||
/**
|
||||
* CapacitorAmberSigner
|
||||
*
|
||||
* Wraps nostr-signer-capacitor-plugin with the CORRECT API as per the
|
||||
* actual compiled source (dist/esm/index.js) — NOT the README docs.
|
||||
* Talks to the native Amber signer (nostr-signer-capacitor-plugin) over the
|
||||
* Capacitor bridge.
|
||||
*
|
||||
* Every method takes packageName as the FIRST positional argument.
|
||||
* signEvent/encrypt/decrypt also require id (unique op id) and npub.
|
||||
* 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)}`;
|
||||
}
|
||||
|
||||
async function getPlugin() {
|
||||
// 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 {
|
||||
const { NostrSignerPlugin } = await import('nostr-signer-capacitor-plugin');
|
||||
if (!NostrSignerPlugin) return null;
|
||||
// JS layer does: native.setPackageName({ packageName })
|
||||
// so the JS wrapper expects a plain string which it wraps into { packageName }
|
||||
// BUT the JS source shows: async setPackageName(packageName) { native.setPackageName({ packageName }) }
|
||||
// So we pass plain string to the JS wrapper — it handles the wrapping
|
||||
await NostrSignerPlugin.setPackageName(AMBER_PACKAGE);
|
||||
return NostrSignerPlugin;
|
||||
} catch (e: unknown) {
|
||||
const code = (e as { code?: string })?.code;
|
||||
if (code !== 'ANDROID_ONLY') {
|
||||
console.error('[CapacitorAmberSigner] getPlugin failed:', e);
|
||||
}
|
||||
if (!Capacitor?.isNativePlatform?.()) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!nativePlugin) {
|
||||
nativePlugin = registerPlugin<NostrSignerNative>('NostrSignerPlugin');
|
||||
}
|
||||
return nativePlugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.');
|
||||
}
|
||||
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);
|
||||
}
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login: get public key from Amber.
|
||||
*
|
||||
* The plugin tries Content Resolver first (silent, returns cached key),
|
||||
* then falls back to opening Amber UI if no cached result.
|
||||
*
|
||||
* The Java bridge calls npubToHex() before returning, so the "npub" field
|
||||
* is actually a hex pubkey string despite the misleading name.
|
||||
* 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();
|
||||
if (!plugin) throw new Error('Capacitor Amber plugin not available — are you on a native build?');
|
||||
|
||||
// Pass null as packageName to skip the Content Resolver silent path
|
||||
// and always open the Amber UI so the user explicitly selects their key.
|
||||
// This prevents returning a stale/wrong cached key.
|
||||
// Pass AMBER_PACKAGE (not null) so the Content Resolver URI is valid.
|
||||
// If Content Resolver has a default account it returns immediately (silent).
|
||||
// If not, it falls through to open Amber UI via intent.
|
||||
const result = await plugin.getPublicKey(AMBER_PACKAGE);
|
||||
const result = await plugin.getPublicKey({ packageName: AMBER_PACKAGE });
|
||||
|
||||
// JSObject from Capacitor — stringify and reparse to get a plain JS object
|
||||
// so property access works reliably
|
||||
// JSObject from Capacitor — normalize to a plain object for reliable access.
|
||||
const plain = JSON.parse(JSON.stringify(result)) as Record<string, string>;
|
||||
|
||||
// Java puts the value in "npub" field but calls npubToHex() first,
|
||||
// so it's actually a 64-char hex string despite the misleading name
|
||||
const value = plain.npub ?? plain.pubkey ?? plain.result ?? '';
|
||||
|
||||
if (!value) throw new Error('Amber returned empty key');
|
||||
|
||||
// Already hex
|
||||
if (/^[0-9a-f]{64}$/i.test(value)) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
// Real npub — decode to hex
|
||||
if (value.startsWith('npub1')) {
|
||||
const decoded = nip19.decode(value);
|
||||
if (decoded.type !== 'npub') throw new Error('Unexpected key format from Amber');
|
||||
|
|
@ -84,7 +116,6 @@ export async function getAmberPublicKeyViaPlugin(): Promise<string> {
|
|||
|
||||
export class CapacitorAmberSigner implements NostrSigner {
|
||||
readonly pubkey: string;
|
||||
// Store the npub for use in signing calls
|
||||
private readonly npub: string;
|
||||
|
||||
constructor(pubkeyHex: string) {
|
||||
|
|
@ -96,99 +127,76 @@ export class CapacitorAmberSigner implements NostrSigner {
|
|||
return this.pubkey;
|
||||
}
|
||||
|
||||
/**
|
||||
* signEvent(packageName, eventJson, id, npub)
|
||||
* Returns { event: string } — signed event JSON
|
||||
*/
|
||||
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
|
||||
const plugin = await getPlugin();
|
||||
if (!plugin) throw new Error('Capacitor Amber plugin not available');
|
||||
|
||||
const id = makeId();
|
||||
const { event: signedJson } = await plugin.signEvent(
|
||||
AMBER_PACKAGE,
|
||||
JSON.stringify(event),
|
||||
id,
|
||||
this.npub,
|
||||
);
|
||||
const { event: signedJson } = await plugin.signEvent({
|
||||
packageName: AMBER_PACKAGE,
|
||||
eventJson: JSON.stringify(event),
|
||||
eventId: makeId(),
|
||||
npub: this.npub,
|
||||
});
|
||||
return JSON.parse(signedJson) as NostrEvent;
|
||||
}
|
||||
|
||||
readonly nip04 = {
|
||||
/**
|
||||
* nip04Encrypt(packageName, plainText, id, pubKey, npub)
|
||||
*/
|
||||
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
||||
const plugin = await getPlugin();
|
||||
if (!plugin) throw new Error('Capacitor Amber plugin not available');
|
||||
const { result } = await plugin.nip04Encrypt(
|
||||
AMBER_PACKAGE,
|
||||
plaintext,
|
||||
makeId(),
|
||||
pubkey,
|
||||
this.npub,
|
||||
);
|
||||
const { result } = await plugin.nip04Encrypt({
|
||||
packageName: AMBER_PACKAGE,
|
||||
plainText: plaintext,
|
||||
pubKey: pubkey,
|
||||
npub: this.npub,
|
||||
id: makeId(),
|
||||
});
|
||||
return result;
|
||||
},
|
||||
/**
|
||||
* nip04Decrypt(packageName, encryptedText, id, pubKey, npub)
|
||||
*/
|
||||
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
||||
const plugin = await getPlugin();
|
||||
if (!plugin) throw new Error('Capacitor Amber plugin not available');
|
||||
const { result } = await plugin.nip04Decrypt(
|
||||
AMBER_PACKAGE,
|
||||
ciphertext,
|
||||
makeId(),
|
||||
pubkey,
|
||||
this.npub,
|
||||
);
|
||||
const { result } = await plugin.nip04Decrypt({
|
||||
packageName: AMBER_PACKAGE,
|
||||
encryptedText: ciphertext,
|
||||
pubKey: pubkey,
|
||||
npub: this.npub,
|
||||
id: makeId(),
|
||||
});
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
readonly nip44 = {
|
||||
/**
|
||||
* nip44Encrypt(packageName, plainText, id, pubKey, npub)
|
||||
*/
|
||||
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
||||
const plugin = await getPlugin();
|
||||
if (!plugin) throw new Error('Capacitor Amber plugin not available');
|
||||
const { result } = await plugin.nip44Encrypt(
|
||||
AMBER_PACKAGE,
|
||||
plaintext,
|
||||
makeId(),
|
||||
pubkey,
|
||||
this.npub,
|
||||
);
|
||||
const { result } = await plugin.nip44Encrypt({
|
||||
packageName: AMBER_PACKAGE,
|
||||
plainText: plaintext,
|
||||
pubKey: pubkey,
|
||||
npub: this.npub,
|
||||
id: makeId(),
|
||||
});
|
||||
return result;
|
||||
},
|
||||
/**
|
||||
* nip44Decrypt(packageName, encryptedText, id, pubKey, npub)
|
||||
*/
|
||||
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
||||
const plugin = await getPlugin();
|
||||
if (!plugin) throw new Error('Capacitor Amber plugin not available');
|
||||
const { result } = await plugin.nip44Decrypt(
|
||||
AMBER_PACKAGE,
|
||||
ciphertext,
|
||||
makeId(),
|
||||
pubkey,
|
||||
this.npub,
|
||||
);
|
||||
const { result } = await plugin.nip44Decrypt({
|
||||
packageName: AMBER_PACKAGE,
|
||||
encryptedText: ciphertext,
|
||||
pubKey: pubkey,
|
||||
npub: this.npub,
|
||||
id: makeId(),
|
||||
});
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* isExternalSignerInstalled(packageName)
|
||||
* Is an external signer (Amber) installed on this device?
|
||||
*/
|
||||
export async function isAmberInstalled(): Promise<boolean> {
|
||||
const plugin = await getPlugin();
|
||||
const plugin = getNativeSigner();
|
||||
if (!plugin) return false;
|
||||
try {
|
||||
const { installed } = await plugin.isExternalSignerInstalled(AMBER_PACKAGE);
|
||||
const { installed } = await plugin.isExternalSignerInstalled({ packageName: AMBER_PACKAGE });
|
||||
return installed;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
|
|||
Loading…
Reference in a new issue