@formstr/signer 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -5
- package/dist/index.cjs +420 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +413 -8
- package/dist/index.js.map +1 -1
- package/dist/{signer-BepGdtJs.d.cts → signer-CQyqBYxq.d.cts} +286 -2
- package/dist/{signer-BepGdtJs.d.ts → signer-CQyqBYxq.d.ts} +286 -2
- package/dist/ui/index.cjs +36 -2
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.cts +30 -4
- package/dist/ui/index.d.ts +30 -4
- package/dist/ui/index.js +36 -2
- package/dist/ui/index.js.map +1 -1
- package/package.json +3 -2
- package/styles/signer.css +10 -0
package/dist/index.js
CHANGED
|
@@ -368,7 +368,7 @@ async function loginWithAndroidSigner(plugin, packageName) {
|
|
|
368
368
|
"@formstr/signer: android signer did not return a package name and none was supplied"
|
|
369
369
|
);
|
|
370
370
|
}
|
|
371
|
-
const { pubkey, npub } =
|
|
371
|
+
const { pubkey, npub } = normalizeNip55Identifier(rawIdentifier);
|
|
372
372
|
return {
|
|
373
373
|
signer: new AndroidSigner(plugin, resolvedPackage, npub, pubkey),
|
|
374
374
|
pubkey,
|
|
@@ -376,7 +376,7 @@ async function loginWithAndroidSigner(plugin, packageName) {
|
|
|
376
376
|
packageName: resolvedPackage
|
|
377
377
|
};
|
|
378
378
|
}
|
|
379
|
-
function
|
|
379
|
+
function normalizeNip55Identifier(rawIdentifier) {
|
|
380
380
|
if (typeof rawIdentifier === "string" && HEX_PUBKEY_RE.test(rawIdentifier)) {
|
|
381
381
|
const pubkey = rawIdentifier.toLowerCase();
|
|
382
382
|
return { pubkey, npub: nip192.npubEncode(pubkey) };
|
|
@@ -386,16 +386,304 @@ function normalizeAndroidIdentifier(rawIdentifier) {
|
|
|
386
386
|
decoded = nip192.decode(rawIdentifier);
|
|
387
387
|
} catch (e) {
|
|
388
388
|
throw new Error(
|
|
389
|
-
`@formstr/signer:
|
|
389
|
+
`@formstr/signer: signer returned an undecodable identifier (got ${describeIdentifier(rawIdentifier)}): ${e.message}`
|
|
390
390
|
);
|
|
391
391
|
}
|
|
392
|
-
if (decoded.type
|
|
392
|
+
if (decoded.type === "npub") {
|
|
393
|
+
return { pubkey: decoded.data, npub: rawIdentifier };
|
|
394
|
+
}
|
|
395
|
+
if (decoded.type === "nprofile") {
|
|
396
|
+
const pubkey = decoded.data.pubkey;
|
|
397
|
+
return { pubkey, npub: nip192.npubEncode(pubkey) };
|
|
398
|
+
}
|
|
399
|
+
throw new Error(
|
|
400
|
+
`@formstr/signer: signer returned a non-pubkey identifier (type=${decoded.type}, got ${describeIdentifier(rawIdentifier)})`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/nip55Web.ts
|
|
405
|
+
import {
|
|
406
|
+
getEventHash as getEventHash2,
|
|
407
|
+
verifyEvent
|
|
408
|
+
} from "nostr-tools";
|
|
409
|
+
var DEFAULT_POLL_INTERVAL_MS = 500;
|
|
410
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
411
|
+
var sentinelCounter = 0;
|
|
412
|
+
function makeSentinel() {
|
|
413
|
+
sentinelCounter += 1;
|
|
414
|
+
return `__formstr_nip55_sentinel_${Date.now()}_${sentinelCounter}__`;
|
|
415
|
+
}
|
|
416
|
+
function abortError() {
|
|
417
|
+
const error = new Error("@formstr/signer: NIP-55 request aborted");
|
|
418
|
+
error.name = "AbortError";
|
|
419
|
+
return error;
|
|
420
|
+
}
|
|
421
|
+
function isNativeShell() {
|
|
422
|
+
const cap = globalThis.Capacitor;
|
|
423
|
+
return typeof cap?.isNativePlatform === "function" && cap.isNativePlatform();
|
|
424
|
+
}
|
|
425
|
+
function isGeckoBrowser() {
|
|
426
|
+
const ua = navigator.userAgent;
|
|
427
|
+
return /Firefox\/|FxiOS\/|Focus\/|Gecko\//.test(ua);
|
|
428
|
+
}
|
|
429
|
+
function browserNip55Transport() {
|
|
430
|
+
return {
|
|
431
|
+
supportStatus() {
|
|
432
|
+
if (typeof navigator === "undefined") {
|
|
433
|
+
return { visible: false, reason: "not-android" };
|
|
434
|
+
}
|
|
435
|
+
if (isNativeShell()) {
|
|
436
|
+
return { visible: false, reason: "native" };
|
|
437
|
+
}
|
|
438
|
+
if (!/Android/i.test(navigator.userAgent)) {
|
|
439
|
+
return { visible: false, reason: "not-android" };
|
|
440
|
+
}
|
|
441
|
+
if (typeof navigator.clipboard?.readText !== "function") {
|
|
442
|
+
return { visible: false, reason: "no-clipboard" };
|
|
443
|
+
}
|
|
444
|
+
if (isGeckoBrowser()) {
|
|
445
|
+
return {
|
|
446
|
+
visible: true,
|
|
447
|
+
reason: "firefox",
|
|
448
|
+
warning: "May not work in Firefox for Android \u2014 the clipboard usually cannot be read automatically. If it hangs, try Chrome, Brave, or the Android app."
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return { visible: true };
|
|
452
|
+
},
|
|
453
|
+
isSupported() {
|
|
454
|
+
return this.supportStatus().visible;
|
|
455
|
+
},
|
|
456
|
+
open(intent) {
|
|
457
|
+
window.open(intent, "_blank");
|
|
458
|
+
},
|
|
459
|
+
readClipboard() {
|
|
460
|
+
return navigator.clipboard.readText();
|
|
461
|
+
},
|
|
462
|
+
writeClipboard(text) {
|
|
463
|
+
return navigator.clipboard.writeText(text);
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
var Nip55WebSigner = class _Nip55WebSigner {
|
|
468
|
+
#transport;
|
|
469
|
+
#pollIntervalMs;
|
|
470
|
+
#timeoutMs;
|
|
471
|
+
#signal;
|
|
472
|
+
#debug;
|
|
473
|
+
#pending = null;
|
|
474
|
+
#pubkey = null;
|
|
475
|
+
constructor(options = {}) {
|
|
476
|
+
this.#transport = options.transport ?? browserNip55Transport();
|
|
477
|
+
this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
478
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
479
|
+
this.#signal = options.signal;
|
|
480
|
+
this.#debug = options.debug;
|
|
481
|
+
this.#pubkey = options.pubkey ?? null;
|
|
482
|
+
}
|
|
483
|
+
#log(message) {
|
|
484
|
+
this.#debug?.(message);
|
|
485
|
+
}
|
|
486
|
+
/** True when the configured transport can actually open a signer app. */
|
|
487
|
+
isSupported() {
|
|
488
|
+
return this.#transport.isSupported();
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Whether to offer the option and whether it works. Prefer this over
|
|
492
|
+
* {@link isSupported} so a host can surface `message` — notably the
|
|
493
|
+
* Firefox case, which is shown but cannot complete.
|
|
494
|
+
*/
|
|
495
|
+
supportStatus() {
|
|
496
|
+
const status = this.#transport.supportStatus;
|
|
497
|
+
if (status) return status.call(this.#transport);
|
|
498
|
+
return { visible: this.#transport.isSupported() };
|
|
499
|
+
}
|
|
500
|
+
async getPublicKey() {
|
|
501
|
+
if (this.#pubkey !== null) return this.#pubkey;
|
|
502
|
+
this.#checkSupport();
|
|
503
|
+
const raw = await this.#request(_Nip55WebSigner.getPublicKeyIntent());
|
|
504
|
+
const { pubkey } = normalizeNip55Identifier(raw);
|
|
505
|
+
this.#pubkey = pubkey;
|
|
506
|
+
return pubkey;
|
|
507
|
+
}
|
|
508
|
+
async signEvent(event) {
|
|
509
|
+
this.#checkSupport();
|
|
510
|
+
const pubkey = this.#pubkey ?? await this.getPublicKey();
|
|
511
|
+
const unsigned = { ...event, pubkey };
|
|
512
|
+
const draftWithId = { ...unsigned, id: getEventHash2(unsigned) };
|
|
513
|
+
const sig = (await this.#request(_Nip55WebSigner.signEventIntent(draftWithId))).trim();
|
|
514
|
+
if (!/^[0-9a-f]{128}$/i.test(sig)) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
"@formstr/signer: NIP-55 signer did not return a hex signature"
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const signed = { ...draftWithId, sig: sig.toLowerCase() };
|
|
520
|
+
if (!verifyEvent(signed)) {
|
|
521
|
+
throw new Error("@formstr/signer: NIP-55 signer returned an invalid signature");
|
|
522
|
+
}
|
|
523
|
+
return signed;
|
|
524
|
+
}
|
|
525
|
+
async nip04Encrypt(peerPubkey, plaintext) {
|
|
526
|
+
this.#checkSupport();
|
|
527
|
+
return this.#request(_Nip55WebSigner.nip04EncryptIntent(peerPubkey, plaintext));
|
|
528
|
+
}
|
|
529
|
+
async nip04Decrypt(peerPubkey, ciphertext) {
|
|
530
|
+
this.#checkSupport();
|
|
531
|
+
return this.#request(_Nip55WebSigner.nip04DecryptIntent(peerPubkey, ciphertext));
|
|
532
|
+
}
|
|
533
|
+
async nip44Encrypt(peerPubkey, plaintext) {
|
|
534
|
+
this.#checkSupport();
|
|
535
|
+
return this.#request(_Nip55WebSigner.nip44EncryptIntent(peerPubkey, plaintext));
|
|
536
|
+
}
|
|
537
|
+
async nip44Decrypt(peerPubkey, ciphertext) {
|
|
538
|
+
this.#checkSupport();
|
|
539
|
+
return this.#request(_Nip55WebSigner.nip44DecryptIntent(peerPubkey, ciphertext));
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Cancel any in-flight request and stop its clipboard poll. Subsequent
|
|
543
|
+
* operations still work — this is a teardown of live resources, not a
|
|
544
|
+
* permanent disable (the {@link Signer} calls it when replacing the
|
|
545
|
+
* active signer).
|
|
546
|
+
*/
|
|
547
|
+
close() {
|
|
548
|
+
if (this.#pending) {
|
|
549
|
+
const pending = this.#pending;
|
|
550
|
+
this.#settle();
|
|
551
|
+
pending.reject(abortError());
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
#checkSupport() {
|
|
555
|
+
const { visible, reason } = this.supportStatus();
|
|
556
|
+
if (visible) return;
|
|
557
|
+
if (reason === "native") {
|
|
558
|
+
throw new Error(
|
|
559
|
+
"@formstr/signer: the browser NIP-55 flow is not for native builds \u2014 use loginWithAndroidSigner() with the Capacitor plugin instead"
|
|
560
|
+
);
|
|
561
|
+
}
|
|
393
562
|
throw new Error(
|
|
394
|
-
|
|
563
|
+
"@formstr/signer: NIP-55 web signing requires an Android browser with clipboard access (a signer app registering the `nostrsigner` scheme must be installed)"
|
|
395
564
|
);
|
|
396
565
|
}
|
|
397
|
-
|
|
398
|
-
|
|
566
|
+
/** One poll tick: read the clipboard and settle if the signer answered. */
|
|
567
|
+
#poll = async (pending) => {
|
|
568
|
+
let text;
|
|
569
|
+
try {
|
|
570
|
+
text = await this.#transport.readClipboard();
|
|
571
|
+
} catch (error) {
|
|
572
|
+
this.#log(`clipboard read failed: ${error.message}`);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (this.#pending !== pending) return;
|
|
576
|
+
const trimmed = text.trim();
|
|
577
|
+
if (trimmed.length === 0) return;
|
|
578
|
+
if (pending.sentinel !== null && trimmed === pending.sentinel) return;
|
|
579
|
+
this.#log(`clipboard result (${trimmed.length} chars)`);
|
|
580
|
+
this.#settle();
|
|
581
|
+
pending.resolve(trimmed);
|
|
582
|
+
};
|
|
583
|
+
#request(intent) {
|
|
584
|
+
this.#checkAborted();
|
|
585
|
+
this.#cancelPending();
|
|
586
|
+
return new Promise((resolve, reject) => {
|
|
587
|
+
const pending = {
|
|
588
|
+
resolve,
|
|
589
|
+
reject,
|
|
590
|
+
sentinel: null,
|
|
591
|
+
poll: null,
|
|
592
|
+
timer: null,
|
|
593
|
+
onAbort: null
|
|
594
|
+
};
|
|
595
|
+
this.#pending = pending;
|
|
596
|
+
if (this.#signal) {
|
|
597
|
+
const onAbort = () => this.#fail(pending, abortError());
|
|
598
|
+
this.#signal.addEventListener("abort", onAbort);
|
|
599
|
+
pending.onAbort = onAbort;
|
|
600
|
+
}
|
|
601
|
+
if (this.#timeoutMs > 0) {
|
|
602
|
+
pending.timer = setTimeout(() => {
|
|
603
|
+
this.#fail(
|
|
604
|
+
pending,
|
|
605
|
+
new Error(
|
|
606
|
+
`@formstr/signer: NIP-55 request timed out after ${this.#timeoutMs}ms (the signer app never returned a result)`
|
|
607
|
+
)
|
|
608
|
+
);
|
|
609
|
+
}, this.#timeoutMs);
|
|
610
|
+
}
|
|
611
|
+
void (async () => {
|
|
612
|
+
try {
|
|
613
|
+
const sentinel = makeSentinel();
|
|
614
|
+
await this.#transport.writeClipboard(sentinel);
|
|
615
|
+
if (this.#pending === pending) pending.sentinel = sentinel;
|
|
616
|
+
this.#log("planted clipboard sentinel");
|
|
617
|
+
} catch (error) {
|
|
618
|
+
this.#log(`sentinel write failed: ${error.message}`);
|
|
619
|
+
}
|
|
620
|
+
if (this.#pending !== pending) return;
|
|
621
|
+
try {
|
|
622
|
+
this.#log(`opening signer app: ${intent.slice(0, 80)}\u2026`);
|
|
623
|
+
this.#transport.open(intent);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
this.#fail(pending, error);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
pending.poll = setInterval(() => {
|
|
629
|
+
void this.#poll(pending);
|
|
630
|
+
}, this.#pollIntervalMs);
|
|
631
|
+
})();
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Reject the current request. Every caller is cleared on settle — the
|
|
636
|
+
* timeout timer and abort listener are removed, and `open()` is
|
|
637
|
+
* synchronous — so `pending` is always the active request here.
|
|
638
|
+
*/
|
|
639
|
+
#fail(pending, error) {
|
|
640
|
+
this.#settle();
|
|
641
|
+
pending.reject(error);
|
|
642
|
+
}
|
|
643
|
+
#settle() {
|
|
644
|
+
const pending = this.#pending;
|
|
645
|
+
this.#pending = null;
|
|
646
|
+
if (pending?.poll) clearInterval(pending.poll);
|
|
647
|
+
if (pending?.timer) clearTimeout(pending.timer);
|
|
648
|
+
if (pending?.onAbort && this.#signal) {
|
|
649
|
+
this.#signal.removeEventListener("abort", pending.onAbort);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
#cancelPending() {
|
|
653
|
+
if (!this.#pending) return;
|
|
654
|
+
const pending = this.#pending;
|
|
655
|
+
this.#settle();
|
|
656
|
+
pending.reject(new Error("@formstr/signer: NIP-55 request superseded"));
|
|
657
|
+
}
|
|
658
|
+
#checkAborted() {
|
|
659
|
+
if (this.#signal?.aborted) throw abortError();
|
|
660
|
+
}
|
|
661
|
+
static getPublicKeyIntent() {
|
|
662
|
+
return "intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end";
|
|
663
|
+
}
|
|
664
|
+
static signEventIntent(draft) {
|
|
665
|
+
return `intent:${encodeURIComponent(
|
|
666
|
+
JSON.stringify(draft)
|
|
667
|
+
)}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end`;
|
|
668
|
+
}
|
|
669
|
+
static nip04EncryptIntent(peerPubkey, plaintext) {
|
|
670
|
+
return _Nip55WebSigner.#cryptoIntent("nip04_encrypt", peerPubkey, plaintext);
|
|
671
|
+
}
|
|
672
|
+
static nip04DecryptIntent(peerPubkey, ciphertext) {
|
|
673
|
+
return _Nip55WebSigner.#cryptoIntent("nip04_decrypt", peerPubkey, ciphertext);
|
|
674
|
+
}
|
|
675
|
+
static nip44EncryptIntent(peerPubkey, plaintext) {
|
|
676
|
+
return _Nip55WebSigner.#cryptoIntent("nip44_encrypt", peerPubkey, plaintext);
|
|
677
|
+
}
|
|
678
|
+
static nip44DecryptIntent(peerPubkey, ciphertext) {
|
|
679
|
+
return _Nip55WebSigner.#cryptoIntent("nip44_decrypt", peerPubkey, ciphertext);
|
|
680
|
+
}
|
|
681
|
+
static #cryptoIntent(type, peerPubkey, payload) {
|
|
682
|
+
return `intent:${encodeURIComponent(
|
|
683
|
+
payload
|
|
684
|
+
)}#Intent;scheme=nostrsigner;S.pubKey=${peerPubkey};S.compressionType=none;S.returnType=signature;S.type=${type};end`;
|
|
685
|
+
}
|
|
686
|
+
};
|
|
399
687
|
|
|
400
688
|
// src/core/signer.ts
|
|
401
689
|
var ACCOUNTS_KEY = "accounts";
|
|
@@ -403,6 +691,7 @@ var ACTIVE_KEY = "active-pubkey";
|
|
|
403
691
|
var Signer = class {
|
|
404
692
|
#storage;
|
|
405
693
|
#defaultAndroidPlugin;
|
|
694
|
+
#nip55WebTransport;
|
|
406
695
|
#appMetadata;
|
|
407
696
|
#accounts = [];
|
|
408
697
|
#activePubkey = null;
|
|
@@ -411,6 +700,7 @@ var Signer = class {
|
|
|
411
700
|
constructor(config = {}) {
|
|
412
701
|
this.#storage = config.storage ?? localStorageAdapter(config.storageKeyPrefix);
|
|
413
702
|
this.#defaultAndroidPlugin = config.androidSignerPlugin;
|
|
703
|
+
this.#nip55WebTransport = config.nip55WebTransport;
|
|
414
704
|
this.#appMetadata = {
|
|
415
705
|
name: config.appName,
|
|
416
706
|
url: config.appUrl,
|
|
@@ -444,8 +734,28 @@ var Signer = class {
|
|
|
444
734
|
else this.#accounts.push(account);
|
|
445
735
|
this.#persistAccounts();
|
|
446
736
|
}
|
|
737
|
+
/**
|
|
738
|
+
* Release the currently-held signer, if it has a `close()`. Called
|
|
739
|
+
* whenever the active signer is replaced or cleared so live resources
|
|
740
|
+
* (bunker subscriptions, `visibilitychange` listeners, in-flight NIP-55
|
|
741
|
+
* requests) don't outlive the session. Errors are swallowed — teardown
|
|
742
|
+
* must never block a login/switch/logout.
|
|
743
|
+
*/
|
|
744
|
+
#closeActiveSigner() {
|
|
745
|
+
const signer = this.#activeSigner;
|
|
746
|
+
if (!signer?.close) return;
|
|
747
|
+
try {
|
|
748
|
+
const result = signer.close();
|
|
749
|
+
if (result && typeof result.catch === "function") {
|
|
750
|
+
result.catch(() => {
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
} catch {
|
|
754
|
+
}
|
|
755
|
+
}
|
|
447
756
|
#setActive(account, signer) {
|
|
448
757
|
const wasDifferent = this.#activePubkey !== null && this.#activePubkey !== account.pubkey;
|
|
758
|
+
if (this.#activeSigner !== signer) this.#closeActiveSigner();
|
|
449
759
|
this.#activePubkey = account.pubkey;
|
|
450
760
|
this.#activeSigner = signer;
|
|
451
761
|
this.#persistActive();
|
|
@@ -637,6 +947,84 @@ var Signer = class {
|
|
|
637
947
|
this.#setActive(account, result.signer);
|
|
638
948
|
return account;
|
|
639
949
|
}
|
|
950
|
+
/**
|
|
951
|
+
* Whether `loginWithNip55Web` can run in this environment — a plain
|
|
952
|
+
* Android browser with async clipboard access. False in a Capacitor
|
|
953
|
+
* native shell (use {@link loginWithAndroidSigner} there), and on
|
|
954
|
+
* desktop/iOS/SSR.
|
|
955
|
+
*
|
|
956
|
+
* A **capability** check, not an availability one: there is no web API
|
|
957
|
+
* to detect an installed Android app, so this says nothing about
|
|
958
|
+
* whether a signer app is actually installed. Use it to hide the
|
|
959
|
+
* browser flow where it cannot work, not to promise that it will.
|
|
960
|
+
*/
|
|
961
|
+
supportsNip55Web(transport) {
|
|
962
|
+
return this.nip55WebSupport(transport).visible;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Whether to offer the browser NIP-55 option, plus an advisory `warning`
|
|
966
|
+
* where it is known to be flaky. Prefer this in UI code.
|
|
967
|
+
*
|
|
968
|
+
* Nothing here is a hard block. `visible: false` covers only environments
|
|
969
|
+
* where the mechanism cannot exist — a Capacitor native shell (the plugin
|
|
970
|
+
* path is better) and non-Android platforms (no `nostrsigner` handler).
|
|
971
|
+
* Firefox for Android is `visible: true` **with a warning**, because it
|
|
972
|
+
* advertises `readText` but never grants a persistent permission, so the
|
|
973
|
+
* poll loop usually cannot complete. Users are still allowed to try.
|
|
974
|
+
*/
|
|
975
|
+
nip55WebSupport(transport) {
|
|
976
|
+
const t = transport ?? this.#nip55WebTransport ?? browserNip55Transport();
|
|
977
|
+
if (t.supportStatus) return t.supportStatus();
|
|
978
|
+
return { visible: t.isSupported() };
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Sign in via a NIP-55 Android external signer (Amber, etc) **from a
|
|
982
|
+
* plain browser**, with no Capacitor/native bridge. Opens the installed
|
|
983
|
+
* signer app through a `nostrsigner` intent and reads the result back
|
|
984
|
+
* from the clipboard once the user returns to the tab. Because the
|
|
985
|
+
* intent names no package, this works with any app that registered the
|
|
986
|
+
* `nostrsigner` scheme — one opens directly, several show the Android
|
|
987
|
+
* "Open with" chooser.
|
|
988
|
+
*
|
|
989
|
+
* Not for native builds — inside a Capacitor shell use
|
|
990
|
+
* {@link loginWithAndroidSigner}, which needs no clipboard and no
|
|
991
|
+
* per-operation approval.
|
|
992
|
+
*
|
|
993
|
+
* Every operation is a separate approval, and a rejection is
|
|
994
|
+
* indistinguishable from the user simply not returning, so callers must
|
|
995
|
+
* impose their own timeout. Prefer NIP-46 when a persistent session is
|
|
996
|
+
* acceptable — the NIP-55 spec recommends it for web clients.
|
|
997
|
+
*
|
|
998
|
+
* @throws if the environment cannot run the flow (not Android, no async
|
|
999
|
+
* clipboard) or the signer returns an unexpected value.
|
|
1000
|
+
*/
|
|
1001
|
+
async loginWithNip55Web(options = {}) {
|
|
1002
|
+
const transport = options.transport ?? this.#nip55WebTransport;
|
|
1003
|
+
const pairing = new Nip55WebSigner({
|
|
1004
|
+
transport,
|
|
1005
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
1006
|
+
timeoutMs: options.timeoutMs,
|
|
1007
|
+
signal: options.signal,
|
|
1008
|
+
debug: options.debug
|
|
1009
|
+
});
|
|
1010
|
+
const pubkey = await pairing.getPublicKey();
|
|
1011
|
+
const signer = new Nip55WebSigner({
|
|
1012
|
+
transport,
|
|
1013
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
1014
|
+
timeoutMs: options.timeoutMs,
|
|
1015
|
+
pubkey,
|
|
1016
|
+
debug: options.debug
|
|
1017
|
+
});
|
|
1018
|
+
const npub = nip193.npubEncode(pubkey);
|
|
1019
|
+
const account = {
|
|
1020
|
+
npub,
|
|
1021
|
+
pubkey,
|
|
1022
|
+
method: "nip55-web"
|
|
1023
|
+
};
|
|
1024
|
+
this.#upsertAccount(account);
|
|
1025
|
+
this.#setActive(account, signer);
|
|
1026
|
+
return account;
|
|
1027
|
+
}
|
|
640
1028
|
/** Snapshot of every persisted account, in insertion order. */
|
|
641
1029
|
listAccounts() {
|
|
642
1030
|
return [...this.#accounts];
|
|
@@ -690,6 +1078,10 @@ var Signer = class {
|
|
|
690
1078
|
* {@link loginWithAndroidSigner} performs and that — on Amber —
|
|
691
1079
|
* surfaces as a permission prompt every cold start.
|
|
692
1080
|
*
|
|
1081
|
+
* - `nip55-web`: constructs a {@link Nip55WebSigner} with the stored
|
|
1082
|
+
* `pubkey` cached. Like `android`, this opens no signer app during
|
|
1083
|
+
* unlock; the first sign/encrypt call is what prompts.
|
|
1084
|
+
*
|
|
693
1085
|
* - `ncryptsec`: returns `null`. There is no silent path — the user's
|
|
694
1086
|
* passphrase isn't (and shouldn't be) persisted. The caller must
|
|
695
1087
|
* drive the passphrase prompt and call {@link loginWithNcryptsec}.
|
|
@@ -738,6 +1130,14 @@ var Signer = class {
|
|
|
738
1130
|
this.#setActive(account, signer);
|
|
739
1131
|
return signer;
|
|
740
1132
|
}
|
|
1133
|
+
case "nip55-web": {
|
|
1134
|
+
const signer = new Nip55WebSigner({
|
|
1135
|
+
transport: this.#nip55WebTransport,
|
|
1136
|
+
pubkey: account.pubkey
|
|
1137
|
+
});
|
|
1138
|
+
this.#setActive(account, signer);
|
|
1139
|
+
return signer;
|
|
1140
|
+
}
|
|
741
1141
|
case "ncryptsec":
|
|
742
1142
|
return null;
|
|
743
1143
|
}
|
|
@@ -752,6 +1152,7 @@ var Signer = class {
|
|
|
752
1152
|
async switchAccount(pubkey) {
|
|
753
1153
|
const account = this.#accounts.find((a) => a.pubkey === pubkey);
|
|
754
1154
|
if (!account) throw new Error(`switchAccount: no account for pubkey ${pubkey}`);
|
|
1155
|
+
this.#closeActiveSigner();
|
|
755
1156
|
this.#activePubkey = pubkey;
|
|
756
1157
|
this.#activeSigner = null;
|
|
757
1158
|
this.#persistActive();
|
|
@@ -768,6 +1169,7 @@ var Signer = class {
|
|
|
768
1169
|
this.#accounts = this.#accounts.filter((a) => a.pubkey !== target);
|
|
769
1170
|
this.#persistAccounts();
|
|
770
1171
|
if (this.#activePubkey === target) {
|
|
1172
|
+
this.#closeActiveSigner();
|
|
771
1173
|
this.#activePubkey = null;
|
|
772
1174
|
this.#activeSigner = null;
|
|
773
1175
|
this.#persistActive();
|
|
@@ -794,7 +1196,9 @@ export {
|
|
|
794
1196
|
BunkerSigner,
|
|
795
1197
|
ExtensionSigner,
|
|
796
1198
|
LocalSigner,
|
|
1199
|
+
Nip55WebSigner,
|
|
797
1200
|
Signer,
|
|
1201
|
+
browserNip55Transport,
|
|
798
1202
|
bytesToHex,
|
|
799
1203
|
connectWithBunkerUri,
|
|
800
1204
|
createSigner,
|
|
@@ -805,6 +1209,7 @@ export {
|
|
|
805
1209
|
hexToBytes,
|
|
806
1210
|
initiateNostrConnect,
|
|
807
1211
|
localStorageAdapter,
|
|
808
|
-
loginWithAndroidSigner
|
|
1212
|
+
loginWithAndroidSigner,
|
|
1213
|
+
normalizeNip55Identifier
|
|
809
1214
|
};
|
|
810
1215
|
//# sourceMappingURL=index.js.map
|