Root cause found in the plugin's native getPublicKey (NostrSignerPlugin.java): when a packageName is set, it FIRST tries Amber's ContentProvider (background, no-UI). On GrapheneOS that cross-app ContentResolver query is blocked and never returns, so it never falls through to startActivityForResult(nostrsigner:) — the branch that actually opens Amber's approval UI. Result: eternal "Waiting for Amber". Fix: getAmberPublicKeyViaPlugin no longer calls setPackageName and no longer passes packageName; it passes only the get_public_key permission. With no package name the plugin skips the content-resolver branch and launches the intent directly, opening Amber's UI. Amber returns both pubkey and its package, so later signing still works. Paired with the auto-injected <queries> manifest block so the OS can resolve the intent in the first place. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
307 lines
12 KiB
TypeScript
307 lines
12 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 = false;
|
|
async function ensurePackageName(plugin: NostrSignerNative): Promise<void> {
|
|
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<void>((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<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> {
|
|
// Get the raw native bridge directly. CRITICALLY we do NOT call
|
|
// setPackageName here and we do NOT pass packageName to getPublicKey.
|
|
//
|
|
// WHY: the native getPublicKey first tries Amber's ContentProvider (a
|
|
// background, no-UI query) whenever a packageName is known. On GrapheneOS that
|
|
// cross-app ContentResolver query is blocked/stalls and never returns cleanly,
|
|
// so the code never falls through to the intent that actually opens Amber's
|
|
// approval screen → eternal "Waiting for Amber". By omitting packageName, the
|
|
// plugin skips the content-resolver branch and goes STRAIGHT to
|
|
// startActivityForResult(nostrsigner:) which launches Amber's UI. Amber returns
|
|
// both the pubkey AND its package, so subsequent signing still works.
|
|
const plugin = getNativeSigner();
|
|
if (!plugin) {
|
|
throw new Error('Amber signing is only available in the SkateSpots Android app.');
|
|
}
|
|
|
|
const result = await withTimeout(
|
|
plugin.getPublicKey({ permissions: JSON.stringify([{ type: 'get_public_key' }]) }),
|
|
'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;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* True when we're running inside the native Capacitor app AND the native
|
|
* NostrSigner bridge is usable. This is the ONLY reliable native check —
|
|
* reading `window.Capacitor` from a component can be false at mount time or in
|
|
* builds where Capacitor doesn't attach itself to `window`. Always call this at
|
|
* the moment of the action (e.g. inside the login click handler), not at render.
|
|
*/
|
|
export function isNativeAmberAvailable(): boolean {
|
|
try {
|
|
return !!Capacitor?.isNativePlatform?.() && !!getNativeSigner();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|