@formstr/signer 0.2.2 → 0.3.0

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.
@@ -68,6 +68,155 @@ interface AndroidLoginResult {
68
68
  packageName: string;
69
69
  }
70
70
  declare function loginWithAndroidSigner(plugin: AndroidSignerPlugin, packageName?: string): Promise<AndroidLoginResult>;
71
+ /**
72
+ * Normalize whatever a NIP-55 transport handed us in the pubkey slot into a
73
+ * `{ pubkey, npub }` pair. Accepts:
74
+ * - a 32-byte lowercase hex pubkey (newer Amber builds return this),
75
+ * - a bech32 `npub1…` (the original NIP-55 spec shape),
76
+ * - a bech32 `nprofile1…`, which the web/clipboard transport may return.
77
+ * Throws a debuggable error for anything else, including the preview of
78
+ * what was actually received so the caller can triage.
79
+ *
80
+ * Shared by the Capacitor (`loginWithAndroidSigner`) and pure-web
81
+ * (`nip55Web.ts`) flows — both transports are NIP-55 and must agree on
82
+ * how identifiers normalize.
83
+ */
84
+ declare function normalizeNip55Identifier(rawIdentifier: unknown): {
85
+ pubkey: string;
86
+ npub: string;
87
+ };
88
+
89
+ /**
90
+ * NIP-55 over the web, without a Capacitor/native bridge.
91
+ *
92
+ * A plain browser page cannot call an Android signer in the background, so
93
+ * this transport hands the OS a `nostrsigner` intent and then reads the
94
+ * result back from the **clipboard** once the user returns to the tab. It
95
+ * is the mechanism used by applesauce's `AmberClipboardSigner` and
96
+ * gitworkshop's "Use Amber" button; no package is named in the intent, so
97
+ * Android resolves it against whatever app registered the scheme — one
98
+ * installed signer opens directly, several show the "Open with" chooser.
99
+ *
100
+ * The alternative NIP-55 web path — `?callbackUrl=` — is not used here: it
101
+ * navigates the page away and back, which cannot be hidden behind a
102
+ * promise-returning {@link ActiveSigner}. Use NIP-46 when a persistent
103
+ * session is wanted; the spec recommends it for web clients.
104
+ *
105
+ * @see https://github.com/nostr-protocol/nips/blob/master/55.md
106
+ */
107
+ interface Nip55WebTransport {
108
+ /**
109
+ * Whether this environment can run the intent + clipboard flow at all.
110
+ * The package's browser transport requires an Android user agent and the
111
+ * async clipboard API; anything else must fail loudly rather than open a
112
+ * dead intent.
113
+ */
114
+ isSupported(): boolean;
115
+ /** Hand the intent URI to the OS (a browser typically `window.open`s it). */
116
+ open(intent: string): void;
117
+ /** Read the current clipboard text. */
118
+ readClipboard(): Promise<string>;
119
+ /**
120
+ * Overwrite the clipboard. Used to plant a sentinel before opening the
121
+ * signer, so the result can be told apart from whatever the user had
122
+ * copied earlier. Best-effort — a failure just means we fall back to
123
+ * accepting the first non-empty read.
124
+ */
125
+ writeClipboard(text: string): Promise<void>;
126
+ }
127
+ interface Nip55WebOptions {
128
+ /**
129
+ * Environment bridge. Defaults to a browser implementation
130
+ * ({@link browserNip55Transport}). Tests inject a fake.
131
+ */
132
+ transport?: Nip55WebTransport;
133
+ /**
134
+ * Cached user pubkey. When supplied, {@link Nip55WebSigner.getPublicKey}
135
+ * returns it without a signer roundtrip — used on cold start to avoid a
136
+ * fresh `get_public_key` approval prompt for an already-paired account.
137
+ */
138
+ pubkey?: string;
139
+ /**
140
+ * How often to poll the clipboard while waiting for the signer app to
141
+ * return a result. Default 500ms.
142
+ *
143
+ * Polling, not events: on Android Chrome the return from the signer app
144
+ * fires **no** `visibilitychange`/`focus` event at all (the tab was never
145
+ * reported hidden), so an event-driven read simply never happens.
146
+ * `setInterval` keeps running while the tab is backgrounded, so polling
147
+ * is what actually observes the result.
148
+ */
149
+ pollIntervalMs?: number;
150
+ /**
151
+ * Max time to wait for the signer app to return a result. A rejection is
152
+ * invisible over NIP-55 web (the app just never calls back), so without
153
+ * this a denied or abandoned request hangs forever. Default 120000ms;
154
+ * `0` disables the timeout.
155
+ */
156
+ timeoutMs?: number;
157
+ /**
158
+ * Lifetime abort. Aborts the in-flight request and makes every future
159
+ * request reject immediately (rejections carry `name === 'AbortError'`,
160
+ * matching the NIP-46 flow).
161
+ *
162
+ * This is a property of the *signer*, not of one attempt. Do **not**
163
+ * pass a controller that is aborted once a login modal closes — that
164
+ * would leave the signer permanently unusable. To cancel a single
165
+ * pairing attempt, use {@link Signer.loginWithNip55Web}'s `signal`.
166
+ */
167
+ signal?: AbortSignal;
168
+ /**
169
+ * Diagnostic sink for the intent/clipboard round-trip. The flow is hard
170
+ * to observe (it leaves the page and returns), so a host can pass a
171
+ * logger — e.g. to an on-screen list — to see what actually happened.
172
+ */
173
+ debug?: (message: string) => void;
174
+ }
175
+ /**
176
+ * Browser transport: `window.open` for the intent, `navigator.clipboard`
177
+ * for the result. Globals are read lazily so importing this module stays
178
+ * safe in Node.
179
+ *
180
+ * This transport is for a **plain browser only**. Inside a Capacitor
181
+ * native shell the same device has the real NIP-55 plugin, which supports
182
+ * every method and does not need the clipboard or a per-operation
183
+ * approval, so this flow is not offered there — a native host should use
184
+ * `loginWithAndroidSigner` instead.
185
+ */
186
+ declare function browserNip55Transport(): Nip55WebTransport;
187
+ /**
188
+ * {@link ActiveSigner} backed by any installed NIP-55 signer app, used from
189
+ * a plain (Android) browser via intents + clipboard.
190
+ *
191
+ * Every operation is a separate user approval — there is no background
192
+ * channel and no way to learn that the user rejected a request, so callers
193
+ * must impose their own timeout (the flow simply never resolves otherwise).
194
+ */
195
+ declare class Nip55WebSigner implements ActiveSigner {
196
+ #private;
197
+ constructor(options?: Nip55WebOptions);
198
+ /** True when the configured transport can actually open a signer app. */
199
+ isSupported(): boolean;
200
+ getPublicKey(): Promise<string>;
201
+ signEvent(event: EventTemplate): Promise<Event>;
202
+ nip04Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
203
+ nip04Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
204
+ nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
205
+ nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
206
+ /**
207
+ * Cancel any in-flight request and stop its clipboard poll. Subsequent
208
+ * operations still work — this is a teardown of live resources, not a
209
+ * permanent disable (the {@link Signer} calls it when replacing the
210
+ * active signer).
211
+ */
212
+ close(): void;
213
+ static getPublicKeyIntent(): string;
214
+ static signEventIntent(draft: object): string;
215
+ static nip04EncryptIntent(peerPubkey: string, plaintext: string): string;
216
+ static nip04DecryptIntent(peerPubkey: string, ciphertext: string): string;
217
+ static nip44EncryptIntent(peerPubkey: string, plaintext: string): string;
218
+ static nip44DecryptIntent(peerPubkey: string, ciphertext: string): string;
219
+ }
71
220
 
72
221
  interface StorageAdapter {
73
222
  get(key: string): string | null;
@@ -82,8 +231,10 @@ declare function localStorageAdapter(prefix?: string): StorageAdapter;
82
231
  * - `nip46`: NIP-46 remote signer (bunker URI or nostrconnect QR).
83
232
  * - `ncryptsec`: NIP-49 encrypted nsec — decrypted into memory on unlock.
84
233
  * - `android`: NIP-55 Android external signer app via a Capacitor plugin.
234
+ * - `nip55-web`: NIP-55 Android external signer app from a plain browser,
235
+ * via `nostrsigner` intents + clipboard (no native bridge).
85
236
  */
86
- type LoginMethod = 'extension' | 'nip46' | 'ncryptsec' | 'android';
237
+ type LoginMethod = 'extension' | 'nip46' | 'ncryptsec' | 'android' | 'nip55-web';
87
238
  /**
88
239
  * Serialized account record persisted by the {@link StorageAdapter}.
89
240
  *
@@ -142,6 +293,14 @@ interface ActiveSigner {
142
293
  nip04Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
143
294
  nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
144
295
  nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
296
+ /**
297
+ * Optional teardown, called by {@link Signer} when this signer is
298
+ * replaced — on a subsequent login/unlock, `switchAccount`, or
299
+ * `logout`. Implementations with live resources (an open bunker relay
300
+ * subscription, a clipboard poll, an in-flight request) should release
301
+ * them here. Omitted by stateless signers.
302
+ */
303
+ close?(): void | Promise<void>;
145
304
  }
146
305
  interface RelayMismatchInfo {
147
306
  userRelays: string[];
@@ -182,6 +341,33 @@ interface UnlockOptions {
182
341
  */
183
342
  pool?: AbstractSimplePool;
184
343
  }
344
+ /**
345
+ * Options for {@link Signer.loginWithNip55Web} — the pure-browser NIP-55
346
+ * flow (Android intents + clipboard, no Capacitor bridge).
347
+ */
348
+ interface Nip55WebLoginOptions {
349
+ /**
350
+ * Environment bridge for this call. Falls back to
351
+ * {@link SignerConfig.nip55WebTransport}, then to the browser default.
352
+ */
353
+ transport?: Nip55WebTransport;
354
+ /**
355
+ * How often to poll the clipboard while waiting for the signer app.
356
+ * Default 500ms. Polling is required because returning from the signer
357
+ * fires no foreground event on Android Chrome.
358
+ */
359
+ pollIntervalMs?: number;
360
+ /**
361
+ * Max time to wait for the signer app. NIP-55 web has no rejection
362
+ * signal, so a denied/abandoned request never resolves without this.
363
+ * Default 120000ms; `0` disables it.
364
+ */
365
+ timeoutMs?: number;
366
+ /** Abort the pairing request (rejects with `name === 'AbortError'`). */
367
+ signal?: AbortSignal;
368
+ /** Diagnostic sink for the intent/clipboard round-trip. */
369
+ debug?: (message: string) => void;
370
+ }
185
371
  interface NostrConnectOptions {
186
372
  relays: string[];
187
373
  metadata?: {
@@ -251,6 +437,12 @@ interface SignerConfig {
251
437
  * or `listAndroidSignerApps(plugin)`.
252
438
  */
253
439
  androidSignerPlugin?: AndroidSignerPlugin;
440
+ /**
441
+ * Environment bridge for the pure-web NIP-55 flow (Android browser,
442
+ * intents + clipboard). Defaults to a browser implementation; supply a
443
+ * stub in tests or non-browser hosts.
444
+ */
445
+ nip55WebTransport?: Nip55WebTransport;
254
446
  }
255
447
 
256
448
  /**
@@ -346,6 +538,40 @@ declare class Signer {
346
538
  * resolved to a package name, or the user denies the request.
347
539
  */
348
540
  loginWithAndroidSigner(options?: AndroidLoginOptions): Promise<StoredAccount>;
541
+ /**
542
+ * Whether `loginWithNip55Web` can run in this environment — a plain
543
+ * Android browser with async clipboard access. False in a Capacitor
544
+ * native shell (use {@link loginWithAndroidSigner} there), and on
545
+ * desktop/iOS/SSR.
546
+ *
547
+ * A **capability** check, not an availability one: there is no web API
548
+ * to detect an installed Android app, so this says nothing about
549
+ * whether a signer app is actually installed. Use it to hide the
550
+ * browser flow where it cannot work, not to promise that it will.
551
+ */
552
+ supportsNip55Web(transport?: Nip55WebTransport): boolean;
553
+ /**
554
+ * Sign in via a NIP-55 Android external signer (Amber, etc) **from a
555
+ * plain browser**, with no Capacitor/native bridge. Opens the installed
556
+ * signer app through a `nostrsigner` intent and reads the result back
557
+ * from the clipboard once the user returns to the tab. Because the
558
+ * intent names no package, this works with any app that registered the
559
+ * `nostrsigner` scheme — one opens directly, several show the Android
560
+ * "Open with" chooser.
561
+ *
562
+ * Not for native builds — inside a Capacitor shell use
563
+ * {@link loginWithAndroidSigner}, which needs no clipboard and no
564
+ * per-operation approval.
565
+ *
566
+ * Every operation is a separate approval, and a rejection is
567
+ * indistinguishable from the user simply not returning, so callers must
568
+ * impose their own timeout. Prefer NIP-46 when a persistent session is
569
+ * acceptable — the NIP-55 spec recommends it for web clients.
570
+ *
571
+ * @throws if the environment cannot run the flow (not Android, no async
572
+ * clipboard) or the signer returns an unexpected value.
573
+ */
574
+ loginWithNip55Web(options?: Nip55WebLoginOptions): Promise<StoredAccount>;
349
575
  /** Snapshot of every persisted account, in insertion order. */
350
576
  listAccounts(): StoredAccount[];
351
577
  /**
@@ -392,6 +618,10 @@ declare class Signer {
392
618
  * {@link loginWithAndroidSigner} performs and that — on Amber —
393
619
  * surfaces as a permission prompt every cold start.
394
620
  *
621
+ * - `nip55-web`: constructs a {@link Nip55WebSigner} with the stored
622
+ * `pubkey` cached. Like `android`, this opens no signer app during
623
+ * unlock; the first sign/encrypt call is what prompts.
624
+ *
395
625
  * - `ncryptsec`: returns `null`. There is no silent path — the user's
396
626
  * passphrase isn't (and shouldn't be) persisted. The caller must
397
627
  * drive the passphrase prompt and call {@link loginWithNcryptsec}.
@@ -428,4 +658,4 @@ declare class Signer {
428
658
  /** Convenience wrapper around `new Signer(config)`. */
429
659
  declare function createSigner(config?: SignerConfig): Signer;
430
660
 
431
- export { type ActiveSigner as A, type BunkerLoginOptions as B, type LoginMethod as L, type NostrConnectOptions as N, type RelayMismatchHandler as R, Signer as S, type UnlockOptions as U, type AndroidLoginOptions as a, type AndroidLoginResult as b, AndroidSigner as c, type AndroidSignerAppInfo as d, type AndroidSignerPlugin as e, type RelayMismatchInfo as f, type SignerConfig as g, type SignerEvent as h, type StorageAdapter as i, type StoredAccount as j, createSigner as k, localStorageAdapter as l, loginWithAndroidSigner as m };
661
+ export { type ActiveSigner as A, type BunkerLoginOptions as B, type LoginMethod as L, type Nip55WebLoginOptions as N, type RelayMismatchHandler as R, Signer as S, type UnlockOptions as U, type AndroidLoginOptions as a, type AndroidLoginResult as b, AndroidSigner as c, type AndroidSignerAppInfo as d, type AndroidSignerPlugin as e, type Nip55WebOptions as f, Nip55WebSigner as g, type Nip55WebTransport as h, type NostrConnectOptions as i, type RelayMismatchInfo as j, type SignerConfig as k, type SignerEvent as l, type StorageAdapter as m, type StoredAccount as n, browserNip55Transport as o, createSigner as p, localStorageAdapter as q, loginWithAndroidSigner as r, normalizeNip55Identifier as s };
package/dist/ui/index.cjs CHANGED
@@ -41,10 +41,12 @@ var TABS = [
41
41
  { id: "extension", label: "Extension" },
42
42
  { id: "bunker", label: "Bunker URI" },
43
43
  { id: "nostrconnect", label: "Remote (QR)" },
44
+ { id: "nip55web", label: "Signer app" },
44
45
  { id: "android", label: "Android" }
45
46
  ];
46
- function renderLoginHtml() {
47
- const tabs = TABS.map(
47
+ function renderLoginHtml(options = {}) {
48
+ const includeNip55Web = options.includeNip55Web ?? true;
49
+ const tabs = TABS.filter((t) => t.id !== "nip55web" || includeNip55Web).map(
48
50
  (t) => `<button class="nostr-signer__tab nostr-signer__tab--${t.id}" type="button" data-tab="${t.id}">${t.label}</button>`
49
51
  ).join("");
50
52
  return `<div class="nostr-signer__root">
@@ -101,6 +103,10 @@ function renderLoginHtml() {
101
103
  <button class="nostr-signer__button nostr-signer__button--secondary" type="button" data-action="nostrconnect-cancel">Cancel</button>
102
104
  </div>
103
105
  </section>
106
+ ${includeNip55Web ? `<section class="nostr-signer__panel nostr-signer__panel--nip55web" data-panel="nip55web" hidden>
107
+ <p class="nostr-signer__hint">Sign in with a signer app installed on this Android device, using the browser (NIP-55). You&rsquo;ll be asked to approve each signature in the app.</p>
108
+ <button class="nostr-signer__button nostr-signer__button--primary" type="button" data-action="nip55web-login">Open signer app</button>
109
+ </section>` : ""}
104
110
  <section class="nostr-signer__panel nostr-signer__panel--android" data-panel="android" hidden>
105
111
  <p class="nostr-signer__hint">Sign in with an Android external signer app (NIP-55) such as Amber.</p>
106
112
  <p class="nostr-signer__status" data-region="android-status">Loading installed signers&hellip;</p>
@@ -119,6 +125,7 @@ function renderLoginHtml() {
119
125
  function attachLoginListeners(rootEl, signer, handlers = {}) {
120
126
  const detachers = [];
121
127
  let nostrConnectAbort = null;
128
+ let nip55WebAbort = null;
122
129
  const on = (el, event, handler) => {
123
130
  const wrapped = handler;
124
131
  el.addEventListener(event, wrapped);
@@ -239,6 +246,27 @@ function attachLoginListeners(rootEl, signer, handlers = {}) {
239
246
  showError(err);
240
247
  }
241
248
  });
249
+ const nip55WebBtn = rootEl.querySelector(
250
+ '[data-action="nip55web-login"]'
251
+ );
252
+ if (nip55WebBtn) on(nip55WebBtn, "click", async () => {
253
+ clearError();
254
+ const btn = nip55WebBtn;
255
+ btn.disabled = true;
256
+ nip55WebAbort = new AbortController();
257
+ try {
258
+ const account = await signer.loginWithNip55Web({
259
+ signal: nip55WebAbort.signal,
260
+ debug: handlers.onNip55WebDebug
261
+ });
262
+ handlers.onLogin?.(account);
263
+ } catch (err) {
264
+ btn.disabled = false;
265
+ if (err.name !== "AbortError") showError(err);
266
+ } finally {
267
+ nip55WebAbort = null;
268
+ }
269
+ });
242
270
  const bunkerForm = q('[data-form="bunker"]');
243
271
  on(bunkerForm, "submit", async (ev) => {
244
272
  ev.preventDefault();
@@ -321,6 +349,7 @@ function attachLoginListeners(rootEl, signer, handlers = {}) {
321
349
  return {
322
350
  detach: () => {
323
351
  nostrConnectAbort?.abort();
352
+ nip55WebAbort?.abort();
324
353
  for (const d of detachers) d();
325
354
  },
326
355
  selectTab
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/ui/index.ts"],"sourcesContent":["import QRCode from 'qrcode';\nimport type { AbstractSimplePool } from 'nostr-tools/abstract-pool';\nimport type { Signer } from '../core/signer.js';\nimport type { RelayMismatchHandler, StoredAccount } from '../core/types.js';\n\nexport type LoginTab = 'create' | 'ncryptsec' | 'extension' | 'bunker' | 'nostrconnect' | 'android';\n\nexport interface LoginUiHandlers {\n onLogin?: (account: StoredAccount) => void;\n onCancel?: () => void;\n onError?: (error: Error) => void;\n /** Optional: passed through to NIP-46 login methods. */\n pool?: AbstractSimplePool;\n /** Optional: passed through to NIP-46 login methods. */\n onRelayMismatch?: RelayMismatchHandler;\n /** Initial tab to show. Defaults to 'create'. */\n defaultTab?: LoginTab;\n}\n\nexport interface LoginUiBinding {\n detach(): void;\n selectTab(tab: LoginTab): void;\n}\n\nconst TABS: ReadonlyArray<{ id: LoginTab; label: string }> = [\n { id: 'create', label: 'Create' },\n { id: 'ncryptsec', label: 'Existing key' },\n { id: 'extension', label: 'Extension' },\n { id: 'bunker', label: 'Bunker URI' },\n { id: 'nostrconnect', label: 'Remote (QR)' },\n { id: 'android', label: 'Android' },\n];\n\nexport function renderLoginHtml(): string {\n const tabs = TABS.map(\n (t) =>\n `<button class=\"nostr-signer__tab nostr-signer__tab--${t.id}\" type=\"button\" data-tab=\"${t.id}\">${t.label}</button>`,\n ).join('');\n\n return `<div class=\"nostr-signer__root\">\n <div class=\"nostr-signer__modal\">\n <header class=\"nostr-signer__header\">\n <h2 class=\"nostr-signer__title\">Sign in</h2>\n <button class=\"nostr-signer__close\" type=\"button\" data-action=\"cancel\" aria-label=\"Close\">&times;</button>\n </header>\n <nav class=\"nostr-signer__tabs\" role=\"tablist\">${tabs}</nav>\n <main class=\"nostr-signer__body\">\n <section class=\"nostr-signer__panel nostr-signer__panel--create\" data-panel=\"create\">\n <p class=\"nostr-signer__hint\">Generate a new nsec encrypted with your passphrase (NIP-49). Back up the ncryptsec on the next step — it's the only way back in.</p>\n <form class=\"nostr-signer__form\" data-form=\"create\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-create-passphrase\">Passphrase</label>\n <input class=\"nostr-signer__input nostr-signer__input--passphrase\" id=\"nostr-signer-create-passphrase\" type=\"password\" name=\"passphrase\" autocomplete=\"new-password\" required>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Create account</button>\n </form>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--ncryptsec\" data-panel=\"ncryptsec\" hidden>\n <form class=\"nostr-signer__form\" data-form=\"ncryptsec\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-ncryptsec\">ncryptsec1...</label>\n <textarea class=\"nostr-signer__input nostr-signer__input--ncryptsec\" id=\"nostr-signer-ncryptsec\" name=\"ncryptsec\" required></textarea>\n <label class=\"nostr-signer__label\" for=\"nostr-signer-ncryptsec-passphrase\">Passphrase</label>\n <input class=\"nostr-signer__input nostr-signer__input--passphrase\" id=\"nostr-signer-ncryptsec-passphrase\" type=\"password\" name=\"passphrase\" autocomplete=\"current-password\" required>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Sign in</button>\n </form>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--extension\" data-panel=\"extension\" hidden>\n <p class=\"nostr-signer__hint\">Requires a NIP-07 browser extension (nos2x, Alby, etc.).</p>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"button\" data-action=\"extension-login\">Sign in with extension</button>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--bunker\" data-panel=\"bunker\" hidden>\n <form class=\"nostr-signer__form\" data-form=\"bunker\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-bunker-uri\">bunker:// URI</label>\n <textarea class=\"nostr-signer__input nostr-signer__input--bunker-uri\" id=\"nostr-signer-bunker-uri\" name=\"uri\" placeholder=\"bunker://&lt;pubkey&gt;?relay=wss://...\" required></textarea>\n <label class=\"nostr-signer__label\" for=\"nostr-signer-bunker-perms\">Permissions (optional, comma-separated)</label>\n <input class=\"nostr-signer__input nostr-signer__input--perms\" id=\"nostr-signer-bunker-perms\" name=\"perms\" placeholder=\"sign_event:1, nip44_encrypt\">\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Connect</button>\n </form>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--nostrconnect\" data-panel=\"nostrconnect\" hidden>\n <form class=\"nostr-signer__form\" data-form=\"nostrconnect\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-nc-relays\">Relays (comma-separated)</label>\n <input class=\"nostr-signer__input nostr-signer__input--relays\" id=\"nostr-signer-nc-relays\" name=\"relays\" placeholder=\"wss://relay.example, wss://other\" required>\n <label class=\"nostr-signer__label\" for=\"nostr-signer-nc-perms\">Permissions (optional, comma-separated)</label>\n <input class=\"nostr-signer__input nostr-signer__input--perms\" id=\"nostr-signer-nc-perms\" name=\"perms\" placeholder=\"sign_event:1, nip44_encrypt\">\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Generate URI</button>\n </form>\n <div class=\"nostr-signer__qr\" data-region=\"nostrconnect-qr\" hidden>\n <p class=\"nostr-signer__hint\">Scan or paste this into your remote signer:</p>\n <div class=\"nostr-signer__qr-image\" data-region=\"nostrconnect-qr-image\" aria-hidden=\"true\"></div>\n <pre class=\"nostr-signer__qr-uri\" data-region=\"nostrconnect-uri\"></pre>\n <p class=\"nostr-signer__status\" data-region=\"nostrconnect-status\">Waiting for remote signer to pair...</p>\n <button class=\"nostr-signer__button nostr-signer__button--secondary\" type=\"button\" data-action=\"nostrconnect-cancel\">Cancel</button>\n </div>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--android\" data-panel=\"android\" hidden>\n <p class=\"nostr-signer__hint\">Sign in with an Android external signer app (NIP-55) such as Amber.</p>\n <p class=\"nostr-signer__status\" data-region=\"android-status\">Loading installed signers&hellip;</p>\n <ul class=\"nostr-signer__android-apps\" data-region=\"android-apps\" hidden></ul>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--created\" data-panel=\"created\" hidden>\n <p class=\"nostr-signer__hint\">Account created. Back up this encrypted secret key — without it (and your passphrase) you cannot sign in again.</p>\n <pre class=\"nostr-signer__ncryptsec-display\" data-region=\"created-ncryptsec\"></pre>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"button\" data-action=\"created-ack\">I've backed it up</button>\n </section>\n </main>\n <div class=\"nostr-signer__error\" data-region=\"error\" hidden></div>\n </div>\n</div>`;\n}\n\nexport function attachLoginListeners(\n rootEl: HTMLElement,\n signer: Signer,\n handlers: LoginUiHandlers = {},\n): LoginUiBinding {\n const detachers: Array<() => void> = [];\n let nostrConnectAbort: AbortController | null = null;\n\n const on = <K extends keyof HTMLElementEventMap>(\n el: Element,\n event: K,\n handler: (ev: HTMLElementEventMap[K]) => void,\n ): void => {\n const wrapped = handler as EventListener;\n el.addEventListener(event, wrapped);\n detachers.push(() => el.removeEventListener(event, wrapped));\n };\n\n const q = <T extends Element = Element>(selector: string): T =>\n rootEl.querySelector(selector) as T;\n\n const errorEl = q<HTMLDivElement>('[data-region=\"error\"]');\n const showError = (err: Error | string): void => {\n const msg = typeof err === 'string' ? err : err.message;\n errorEl.hidden = false;\n errorEl.textContent = msg;\n if (typeof err !== 'string') handlers.onError?.(err);\n };\n const clearError = (): void => {\n errorEl.hidden = true;\n errorEl.textContent = '';\n };\n\n // ---- android (NIP-55) — declared before selectTab so it can be invoked\n // when the user opens with defaultTab: 'android'.\n const androidStatus = q<HTMLElement>('[data-region=\"android-status\"]');\n const androidList = q<HTMLUListElement>('[data-region=\"android-apps\"]');\n let androidFetchToken = 0;\n\n const refreshAndroidApps = async (): Promise<void> => {\n const token = ++androidFetchToken;\n androidList.hidden = true;\n androidList.innerHTML = '';\n androidStatus.hidden = false;\n androidStatus.textContent = 'Loading installed signers…';\n try {\n const apps = await signer.listAndroidSignerApps();\n if (token !== androidFetchToken) return;\n if (apps.length === 0) {\n androidStatus.textContent =\n 'No NIP-55 signer apps installed. Install Amber (or similar) and try again.';\n return;\n }\n androidStatus.hidden = true;\n for (const app of apps) {\n const li = document.createElement('li');\n li.className = 'nostr-signer__android-app';\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'nostr-signer__button nostr-signer__button--primary';\n btn.dataset.packageName = app.packageName;\n btn.textContent = `${app.name} (${app.packageName})`;\n on(btn, 'click', async () => {\n clearError();\n btn.disabled = true;\n try {\n const account = await signer.loginWithAndroidSigner({\n packageName: app.packageName,\n });\n handlers.onLogin?.(account);\n } catch (err) {\n btn.disabled = false;\n showError(err as Error);\n }\n });\n li.appendChild(btn);\n androidList.appendChild(li);\n }\n androidList.hidden = false;\n } catch (err) {\n if (token !== androidFetchToken) return;\n androidStatus.textContent = (err as Error).message;\n }\n };\n\n const selectTab = (tab: LoginTab): void => {\n rootEl.querySelectorAll<HTMLElement>('[data-panel]').forEach((panel) => {\n panel.hidden = panel.dataset.panel !== tab;\n });\n rootEl.querySelectorAll<HTMLElement>('[data-tab]').forEach((btn) => {\n btn.classList.toggle('nostr-signer__tab--active', btn.dataset.tab === tab);\n });\n clearError();\n if (tab === 'android') void refreshAndroidApps();\n };\n\n selectTab(handlers.defaultTab ?? 'create');\n\n rootEl.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach((btn) => {\n on(btn, 'click', () => selectTab(btn.dataset.tab as LoginTab));\n });\n\n on(q('[data-action=\"cancel\"]'), 'click', () => handlers.onCancel?.());\n\n // ---- create ----\n const createForm = q<HTMLFormElement>('[data-form=\"create\"]');\n on(createForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const passphrase = (createForm.elements.namedItem('passphrase') as HTMLInputElement).value;\n try {\n const { ncryptsec } = await signer.createAccount(passphrase);\n q<HTMLElement>('[data-region=\"created-ncryptsec\"]').textContent = ncryptsec;\n rootEl.querySelectorAll<HTMLElement>('[data-panel]').forEach((p) => {\n p.hidden = p.dataset.panel !== 'created';\n });\n } catch (err) {\n showError(err as Error);\n }\n });\n\n on(q('[data-action=\"created-ack\"]'), 'click', () => {\n const active = signer.getActiveAccount();\n if (active) handlers.onLogin?.(active);\n });\n\n // ---- ncryptsec ----\n const ncryptsecForm = q<HTMLFormElement>('[data-form=\"ncryptsec\"]');\n on(ncryptsecForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const ncryptsec = (\n ncryptsecForm.elements.namedItem('ncryptsec') as HTMLTextAreaElement\n ).value.trim();\n const passphrase = (\n ncryptsecForm.elements.namedItem('passphrase') as HTMLInputElement\n ).value;\n try {\n const account = await signer.loginWithNcryptsec(ncryptsec, passphrase);\n handlers.onLogin?.(account);\n } catch (err) {\n showError(err as Error);\n }\n });\n\n // ---- extension ----\n on(q('[data-action=\"extension-login\"]'), 'click', async () => {\n clearError();\n try {\n const account = await signer.loginWithExtension();\n handlers.onLogin?.(account);\n } catch (err) {\n showError(err as Error);\n }\n });\n\n // ---- bunker URI ----\n const bunkerForm = q<HTMLFormElement>('[data-form=\"bunker\"]');\n on(bunkerForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const uri = (\n bunkerForm.elements.namedItem('uri') as HTMLTextAreaElement\n ).value.trim();\n const perms = (bunkerForm.elements.namedItem('perms') as HTMLInputElement).value\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n try {\n const account = await signer.loginWithBunkerUri(uri, {\n pool: handlers.pool,\n onRelayMismatch: handlers.onRelayMismatch,\n perms: perms.length > 0 ? perms : undefined,\n });\n handlers.onLogin?.(account);\n } catch (err) {\n showError(err as Error);\n }\n });\n\n // ---- nostrconnect ----\n const ncForm = q<HTMLFormElement>('[data-form=\"nostrconnect\"]');\n const ncQrRegion = q<HTMLDivElement>('[data-region=\"nostrconnect-qr\"]');\n const ncQrUri = q<HTMLElement>('[data-region=\"nostrconnect-uri\"]');\n const ncQrImage = q<HTMLDivElement>('[data-region=\"nostrconnect-qr-image\"]');\n const ncStatus = q<HTMLElement>('[data-region=\"nostrconnect-status\"]');\n let ncQrToken = 0;\n\n const resetNostrConnect = (): void => {\n ncForm.hidden = false;\n ncQrRegion.hidden = true;\n ncQrUri.textContent = '';\n ncQrImage.innerHTML = '';\n ncStatus.textContent = 'Waiting for remote signer to pair...';\n ncQrToken++;\n };\n\n const renderNostrConnectQr = async (uri: string): Promise<void> => {\n const token = ++ncQrToken;\n try {\n const svg = await QRCode.toString(uri, {\n type: 'svg',\n margin: 1,\n errorCorrectionLevel: 'M',\n });\n if (token === ncQrToken) ncQrImage.innerHTML = svg;\n } catch {\n // QR rendering is a nice-to-have — the URI text remains usable.\n }\n };\n\n on(ncForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const relays = (ncForm.elements.namedItem('relays') as HTMLInputElement).value\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n if (relays.length === 0) {\n showError('At least one relay is required.');\n return;\n }\n const perms = (ncForm.elements.namedItem('perms') as HTMLInputElement).value\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n\n ncForm.hidden = true;\n ncQrRegion.hidden = false;\n nostrConnectAbort = new AbortController();\n try {\n const account = await signer.loginWithNostrConnect({\n relays,\n perms: perms.length > 0 ? perms : undefined,\n pool: handlers.pool,\n onRelayMismatch: handlers.onRelayMismatch,\n signal: nostrConnectAbort.signal,\n onUri: (uri) => {\n ncQrUri.textContent = uri;\n void renderNostrConnectQr(uri);\n },\n });\n handlers.onLogin?.(account);\n } catch (err) {\n resetNostrConnect();\n if ((err as Error).name !== 'AbortError') showError(err as Error);\n } finally {\n nostrConnectAbort = null;\n }\n });\n\n on(q('[data-action=\"nostrconnect-cancel\"]'), 'click', () => {\n nostrConnectAbort?.abort();\n resetNostrConnect();\n });\n\n return {\n detach: () => {\n nostrConnectAbort?.abort();\n for (const d of detachers) d();\n },\n selectTab,\n };\n}\n"],"mappings":"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","names":["QRCode"]}
1
+ {"version":3,"sources":["../../src/ui/index.ts"],"sourcesContent":["import QRCode from 'qrcode';\nimport type { AbstractSimplePool } from 'nostr-tools/abstract-pool';\nimport type { Signer } from '../core/signer.js';\nimport type { RelayMismatchHandler, StoredAccount } from '../core/types.js';\n\nexport type LoginTab =\n | 'create'\n | 'ncryptsec'\n | 'extension'\n | 'bunker'\n | 'nostrconnect'\n | 'nip55web'\n | 'android';\n\nexport interface LoginUiHandlers {\n onLogin?: (account: StoredAccount) => void;\n onCancel?: () => void;\n onError?: (error: Error) => void;\n /** Optional: passed through to NIP-46 login methods. */\n pool?: AbstractSimplePool;\n /** Optional: passed through to NIP-46 login methods. */\n onRelayMismatch?: RelayMismatchHandler;\n /** Initial tab to show. Defaults to 'create'. */\n defaultTab?: LoginTab;\n /**\n * Optional diagnostic stream for the browser NIP-55 (intent/clipboard)\n * flow. Wire it to an on-screen log when testing on a phone — the flow\n * navigates away, so its behaviour is otherwise hard to observe.\n */\n onNip55WebDebug?: (message: string) => void;\n}\n\nexport interface LoginUiBinding {\n detach(): void;\n selectTab(tab: LoginTab): void;\n}\n\nconst TABS: ReadonlyArray<{ id: LoginTab; label: string }> = [\n { id: 'create', label: 'Create' },\n { id: 'ncryptsec', label: 'Existing key' },\n { id: 'extension', label: 'Extension' },\n { id: 'bunker', label: 'Bunker URI' },\n { id: 'nostrconnect', label: 'Remote (QR)' },\n { id: 'nip55web', label: 'Signer app' },\n { id: 'android', label: 'Android' },\n];\n\nexport interface RenderLoginOptions {\n /**\n * Include the \"Signer app\" tab (browser NIP-55). Defaults to `true`.\n *\n * Pass `signer.supportsNip55Web()` to hide it where it cannot work —\n * notably inside a Capacitor native shell, where\n * `loginWithAndroidSigner` is the right path. Omit it in a native build\n * and the tab would only ever error.\n */\n includeNip55Web?: boolean;\n}\n\nexport function renderLoginHtml(options: RenderLoginOptions = {}): string {\n const includeNip55Web = options.includeNip55Web ?? true;\n const tabs = TABS.filter((t) => t.id !== 'nip55web' || includeNip55Web)\n .map(\n (t) =>\n `<button class=\"nostr-signer__tab nostr-signer__tab--${t.id}\" type=\"button\" data-tab=\"${t.id}\">${t.label}</button>`,\n )\n .join('');\n\n return `<div class=\"nostr-signer__root\">\n <div class=\"nostr-signer__modal\">\n <header class=\"nostr-signer__header\">\n <h2 class=\"nostr-signer__title\">Sign in</h2>\n <button class=\"nostr-signer__close\" type=\"button\" data-action=\"cancel\" aria-label=\"Close\">&times;</button>\n </header>\n <nav class=\"nostr-signer__tabs\" role=\"tablist\">${tabs}</nav>\n <main class=\"nostr-signer__body\">\n <section class=\"nostr-signer__panel nostr-signer__panel--create\" data-panel=\"create\">\n <p class=\"nostr-signer__hint\">Generate a new nsec encrypted with your passphrase (NIP-49). Back up the ncryptsec on the next step — it's the only way back in.</p>\n <form class=\"nostr-signer__form\" data-form=\"create\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-create-passphrase\">Passphrase</label>\n <input class=\"nostr-signer__input nostr-signer__input--passphrase\" id=\"nostr-signer-create-passphrase\" type=\"password\" name=\"passphrase\" autocomplete=\"new-password\" required>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Create account</button>\n </form>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--ncryptsec\" data-panel=\"ncryptsec\" hidden>\n <form class=\"nostr-signer__form\" data-form=\"ncryptsec\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-ncryptsec\">ncryptsec1...</label>\n <textarea class=\"nostr-signer__input nostr-signer__input--ncryptsec\" id=\"nostr-signer-ncryptsec\" name=\"ncryptsec\" required></textarea>\n <label class=\"nostr-signer__label\" for=\"nostr-signer-ncryptsec-passphrase\">Passphrase</label>\n <input class=\"nostr-signer__input nostr-signer__input--passphrase\" id=\"nostr-signer-ncryptsec-passphrase\" type=\"password\" name=\"passphrase\" autocomplete=\"current-password\" required>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Sign in</button>\n </form>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--extension\" data-panel=\"extension\" hidden>\n <p class=\"nostr-signer__hint\">Requires a NIP-07 browser extension (nos2x, Alby, etc.).</p>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"button\" data-action=\"extension-login\">Sign in with extension</button>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--bunker\" data-panel=\"bunker\" hidden>\n <form class=\"nostr-signer__form\" data-form=\"bunker\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-bunker-uri\">bunker:// URI</label>\n <textarea class=\"nostr-signer__input nostr-signer__input--bunker-uri\" id=\"nostr-signer-bunker-uri\" name=\"uri\" placeholder=\"bunker://&lt;pubkey&gt;?relay=wss://...\" required></textarea>\n <label class=\"nostr-signer__label\" for=\"nostr-signer-bunker-perms\">Permissions (optional, comma-separated)</label>\n <input class=\"nostr-signer__input nostr-signer__input--perms\" id=\"nostr-signer-bunker-perms\" name=\"perms\" placeholder=\"sign_event:1, nip44_encrypt\">\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Connect</button>\n </form>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--nostrconnect\" data-panel=\"nostrconnect\" hidden>\n <form class=\"nostr-signer__form\" data-form=\"nostrconnect\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-nc-relays\">Relays (comma-separated)</label>\n <input class=\"nostr-signer__input nostr-signer__input--relays\" id=\"nostr-signer-nc-relays\" name=\"relays\" placeholder=\"wss://relay.example, wss://other\" required>\n <label class=\"nostr-signer__label\" for=\"nostr-signer-nc-perms\">Permissions (optional, comma-separated)</label>\n <input class=\"nostr-signer__input nostr-signer__input--perms\" id=\"nostr-signer-nc-perms\" name=\"perms\" placeholder=\"sign_event:1, nip44_encrypt\">\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Generate URI</button>\n </form>\n <div class=\"nostr-signer__qr\" data-region=\"nostrconnect-qr\" hidden>\n <p class=\"nostr-signer__hint\">Scan or paste this into your remote signer:</p>\n <div class=\"nostr-signer__qr-image\" data-region=\"nostrconnect-qr-image\" aria-hidden=\"true\"></div>\n <pre class=\"nostr-signer__qr-uri\" data-region=\"nostrconnect-uri\"></pre>\n <p class=\"nostr-signer__status\" data-region=\"nostrconnect-status\">Waiting for remote signer to pair...</p>\n <button class=\"nostr-signer__button nostr-signer__button--secondary\" type=\"button\" data-action=\"nostrconnect-cancel\">Cancel</button>\n </div>\n </section>\n ${includeNip55Web ? `<section class=\"nostr-signer__panel nostr-signer__panel--nip55web\" data-panel=\"nip55web\" hidden>\n <p class=\"nostr-signer__hint\">Sign in with a signer app installed on this Android device, using the browser (NIP-55). You&rsquo;ll be asked to approve each signature in the app.</p>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"button\" data-action=\"nip55web-login\">Open signer app</button>\n </section>` : ''}\n <section class=\"nostr-signer__panel nostr-signer__panel--android\" data-panel=\"android\" hidden>\n <p class=\"nostr-signer__hint\">Sign in with an Android external signer app (NIP-55) such as Amber.</p>\n <p class=\"nostr-signer__status\" data-region=\"android-status\">Loading installed signers&hellip;</p>\n <ul class=\"nostr-signer__android-apps\" data-region=\"android-apps\" hidden></ul>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--created\" data-panel=\"created\" hidden>\n <p class=\"nostr-signer__hint\">Account created. Back up this encrypted secret key — without it (and your passphrase) you cannot sign in again.</p>\n <pre class=\"nostr-signer__ncryptsec-display\" data-region=\"created-ncryptsec\"></pre>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"button\" data-action=\"created-ack\">I've backed it up</button>\n </section>\n </main>\n <div class=\"nostr-signer__error\" data-region=\"error\" hidden></div>\n </div>\n</div>`;\n}\n\nexport function attachLoginListeners(\n rootEl: HTMLElement,\n signer: Signer,\n handlers: LoginUiHandlers = {},\n): LoginUiBinding {\n const detachers: Array<() => void> = [];\n let nostrConnectAbort: AbortController | null = null;\n let nip55WebAbort: AbortController | null = null;\n\n const on = <K extends keyof HTMLElementEventMap>(\n el: Element,\n event: K,\n handler: (ev: HTMLElementEventMap[K]) => void,\n ): void => {\n const wrapped = handler as EventListener;\n el.addEventListener(event, wrapped);\n detachers.push(() => el.removeEventListener(event, wrapped));\n };\n\n const q = <T extends Element = Element>(selector: string): T =>\n rootEl.querySelector(selector) as T;\n\n const errorEl = q<HTMLDivElement>('[data-region=\"error\"]');\n const showError = (err: Error | string): void => {\n const msg = typeof err === 'string' ? err : err.message;\n errorEl.hidden = false;\n errorEl.textContent = msg;\n if (typeof err !== 'string') handlers.onError?.(err);\n };\n const clearError = (): void => {\n errorEl.hidden = true;\n errorEl.textContent = '';\n };\n\n // ---- android (NIP-55) — declared before selectTab so it can be invoked\n // when the user opens with defaultTab: 'android'.\n const androidStatus = q<HTMLElement>('[data-region=\"android-status\"]');\n const androidList = q<HTMLUListElement>('[data-region=\"android-apps\"]');\n let androidFetchToken = 0;\n\n const refreshAndroidApps = async (): Promise<void> => {\n const token = ++androidFetchToken;\n androidList.hidden = true;\n androidList.innerHTML = '';\n androidStatus.hidden = false;\n androidStatus.textContent = 'Loading installed signers…';\n try {\n const apps = await signer.listAndroidSignerApps();\n if (token !== androidFetchToken) return;\n if (apps.length === 0) {\n androidStatus.textContent =\n 'No NIP-55 signer apps installed. Install Amber (or similar) and try again.';\n return;\n }\n androidStatus.hidden = true;\n for (const app of apps) {\n const li = document.createElement('li');\n li.className = 'nostr-signer__android-app';\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'nostr-signer__button nostr-signer__button--primary';\n btn.dataset.packageName = app.packageName;\n btn.textContent = `${app.name} (${app.packageName})`;\n on(btn, 'click', async () => {\n clearError();\n btn.disabled = true;\n try {\n const account = await signer.loginWithAndroidSigner({\n packageName: app.packageName,\n });\n handlers.onLogin?.(account);\n } catch (err) {\n btn.disabled = false;\n showError(err as Error);\n }\n });\n li.appendChild(btn);\n androidList.appendChild(li);\n }\n androidList.hidden = false;\n } catch (err) {\n if (token !== androidFetchToken) return;\n androidStatus.textContent = (err as Error).message;\n }\n };\n\n const selectTab = (tab: LoginTab): void => {\n rootEl.querySelectorAll<HTMLElement>('[data-panel]').forEach((panel) => {\n panel.hidden = panel.dataset.panel !== tab;\n });\n rootEl.querySelectorAll<HTMLElement>('[data-tab]').forEach((btn) => {\n btn.classList.toggle('nostr-signer__tab--active', btn.dataset.tab === tab);\n });\n clearError();\n if (tab === 'android') void refreshAndroidApps();\n };\n\n selectTab(handlers.defaultTab ?? 'create');\n\n rootEl.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach((btn) => {\n on(btn, 'click', () => selectTab(btn.dataset.tab as LoginTab));\n });\n\n on(q('[data-action=\"cancel\"]'), 'click', () => handlers.onCancel?.());\n\n // ---- create ----\n const createForm = q<HTMLFormElement>('[data-form=\"create\"]');\n on(createForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const passphrase = (createForm.elements.namedItem('passphrase') as HTMLInputElement).value;\n try {\n const { ncryptsec } = await signer.createAccount(passphrase);\n q<HTMLElement>('[data-region=\"created-ncryptsec\"]').textContent = ncryptsec;\n rootEl.querySelectorAll<HTMLElement>('[data-panel]').forEach((p) => {\n p.hidden = p.dataset.panel !== 'created';\n });\n } catch (err) {\n showError(err as Error);\n }\n });\n\n on(q('[data-action=\"created-ack\"]'), 'click', () => {\n const active = signer.getActiveAccount();\n if (active) handlers.onLogin?.(active);\n });\n\n // ---- ncryptsec ----\n const ncryptsecForm = q<HTMLFormElement>('[data-form=\"ncryptsec\"]');\n on(ncryptsecForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const ncryptsec = (\n ncryptsecForm.elements.namedItem('ncryptsec') as HTMLTextAreaElement\n ).value.trim();\n const passphrase = (\n ncryptsecForm.elements.namedItem('passphrase') as HTMLInputElement\n ).value;\n try {\n const account = await signer.loginWithNcryptsec(ncryptsec, passphrase);\n handlers.onLogin?.(account);\n } catch (err) {\n showError(err as Error);\n }\n });\n\n // ---- extension ----\n on(q('[data-action=\"extension-login\"]'), 'click', async () => {\n clearError();\n try {\n const account = await signer.loginWithExtension();\n handlers.onLogin?.(account);\n } catch (err) {\n showError(err as Error);\n }\n });\n\n // ---- signer app (NIP-55 over the web) ----\n // Absent when renderLoginHtml({ includeNip55Web: false }) omitted the tab.\n const nip55WebBtn = rootEl.querySelector<HTMLButtonElement>(\n '[data-action=\"nip55web-login\"]',\n );\n if (nip55WebBtn) on(nip55WebBtn, 'click', async () => {\n clearError();\n const btn = nip55WebBtn;\n btn.disabled = true;\n nip55WebAbort = new AbortController();\n try {\n const account = await signer.loginWithNip55Web({\n signal: nip55WebAbort.signal,\n debug: handlers.onNip55WebDebug,\n });\n handlers.onLogin?.(account);\n } catch (err) {\n btn.disabled = false;\n // A user cancelling (detach) is not an error worth surfacing.\n if ((err as Error).name !== 'AbortError') showError(err as Error);\n } finally {\n nip55WebAbort = null;\n }\n });\n\n // ---- bunker URI ----\n const bunkerForm = q<HTMLFormElement>('[data-form=\"bunker\"]');\n on(bunkerForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const uri = (\n bunkerForm.elements.namedItem('uri') as HTMLTextAreaElement\n ).value.trim();\n const perms = (bunkerForm.elements.namedItem('perms') as HTMLInputElement).value\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n try {\n const account = await signer.loginWithBunkerUri(uri, {\n pool: handlers.pool,\n onRelayMismatch: handlers.onRelayMismatch,\n perms: perms.length > 0 ? perms : undefined,\n });\n handlers.onLogin?.(account);\n } catch (err) {\n showError(err as Error);\n }\n });\n\n // ---- nostrconnect ----\n const ncForm = q<HTMLFormElement>('[data-form=\"nostrconnect\"]');\n const ncQrRegion = q<HTMLDivElement>('[data-region=\"nostrconnect-qr\"]');\n const ncQrUri = q<HTMLElement>('[data-region=\"nostrconnect-uri\"]');\n const ncQrImage = q<HTMLDivElement>('[data-region=\"nostrconnect-qr-image\"]');\n const ncStatus = q<HTMLElement>('[data-region=\"nostrconnect-status\"]');\n let ncQrToken = 0;\n\n const resetNostrConnect = (): void => {\n ncForm.hidden = false;\n ncQrRegion.hidden = true;\n ncQrUri.textContent = '';\n ncQrImage.innerHTML = '';\n ncStatus.textContent = 'Waiting for remote signer to pair...';\n ncQrToken++;\n };\n\n const renderNostrConnectQr = async (uri: string): Promise<void> => {\n const token = ++ncQrToken;\n try {\n const svg = await QRCode.toString(uri, {\n type: 'svg',\n margin: 1,\n errorCorrectionLevel: 'M',\n });\n if (token === ncQrToken) ncQrImage.innerHTML = svg;\n } catch {\n // QR rendering is a nice-to-have — the URI text remains usable.\n }\n };\n\n on(ncForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const relays = (ncForm.elements.namedItem('relays') as HTMLInputElement).value\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n if (relays.length === 0) {\n showError('At least one relay is required.');\n return;\n }\n const perms = (ncForm.elements.namedItem('perms') as HTMLInputElement).value\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n\n ncForm.hidden = true;\n ncQrRegion.hidden = false;\n nostrConnectAbort = new AbortController();\n try {\n const account = await signer.loginWithNostrConnect({\n relays,\n perms: perms.length > 0 ? perms : undefined,\n pool: handlers.pool,\n onRelayMismatch: handlers.onRelayMismatch,\n signal: nostrConnectAbort.signal,\n onUri: (uri) => {\n ncQrUri.textContent = uri;\n void renderNostrConnectQr(uri);\n },\n });\n handlers.onLogin?.(account);\n } catch (err) {\n resetNostrConnect();\n if ((err as Error).name !== 'AbortError') showError(err as Error);\n } finally {\n nostrConnectAbort = null;\n }\n });\n\n on(q('[data-action=\"nostrconnect-cancel\"]'), 'click', () => {\n nostrConnectAbort?.abort();\n resetNostrConnect();\n });\n\n return {\n detach: () => {\n nostrConnectAbort?.abort();\n nip55WebAbort?.abort();\n for (const d of detachers) d();\n },\n selectTab,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAAmB;AAqCnB,IAAM,OAAuD;AAAA,EAC3D,EAAE,IAAI,UAAU,OAAO,SAAS;AAAA,EAChC,EAAE,IAAI,aAAa,OAAO,eAAe;AAAA,EACzC,EAAE,IAAI,aAAa,OAAO,YAAY;AAAA,EACtC,EAAE,IAAI,UAAU,OAAO,aAAa;AAAA,EACpC,EAAE,IAAI,gBAAgB,OAAO,cAAc;AAAA,EAC3C,EAAE,IAAI,YAAY,OAAO,aAAa;AAAA,EACtC,EAAE,IAAI,WAAW,OAAO,UAAU;AACpC;AAcO,SAAS,gBAAgB,UAA8B,CAAC,GAAW;AACxE,QAAM,kBAAkB,QAAQ,mBAAmB;AACnD,QAAM,OAAO,KAAK,OAAO,CAAC,MAAM,EAAE,OAAO,cAAc,eAAe,EACnE;AAAA,IACC,CAAC,MACC,uDAAuD,EAAE,EAAE,6BAA6B,EAAE,EAAE,KAAK,EAAE,KAAK;AAAA,EAC5G,EACC,KAAK,EAAE;AAEV,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,qDAM4C,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAgDjD,kBAAkB;AAAA;AAAA;AAAA,oBAGN,EAAE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAetB;AAEO,SAAS,qBACd,QACA,QACA,WAA4B,CAAC,GACb;AAChB,QAAM,YAA+B,CAAC;AACtC,MAAI,oBAA4C;AAChD,MAAI,gBAAwC;AAE5C,QAAM,KAAK,CACT,IACA,OACA,YACS;AACT,UAAM,UAAU;AAChB,OAAG,iBAAiB,OAAO,OAAO;AAClC,cAAU,KAAK,MAAM,GAAG,oBAAoB,OAAO,OAAO,CAAC;AAAA,EAC7D;AAEA,QAAM,IAAI,CAA8B,aACtC,OAAO,cAAc,QAAQ;AAE/B,QAAM,UAAU,EAAkB,uBAAuB;AACzD,QAAM,YAAY,CAAC,QAA8B;AAC/C,UAAM,MAAM,OAAO,QAAQ,WAAW,MAAM,IAAI;AAChD,YAAQ,SAAS;AACjB,YAAQ,cAAc;AACtB,QAAI,OAAO,QAAQ,SAAU,UAAS,UAAU,GAAG;AAAA,EACrD;AACA,QAAM,aAAa,MAAY;AAC7B,YAAQ,SAAS;AACjB,YAAQ,cAAc;AAAA,EACxB;AAIA,QAAM,gBAAgB,EAAe,gCAAgC;AACrE,QAAM,cAAc,EAAoB,8BAA8B;AACtE,MAAI,oBAAoB;AAExB,QAAM,qBAAqB,YAA2B;AACpD,UAAM,QAAQ,EAAE;AAChB,gBAAY,SAAS;AACrB,gBAAY,YAAY;AACxB,kBAAc,SAAS;AACvB,kBAAc,cAAc;AAC5B,QAAI;AACF,YAAM,OAAO,MAAM,OAAO,sBAAsB;AAChD,UAAI,UAAU,kBAAmB;AACjC,UAAI,KAAK,WAAW,GAAG;AACrB,sBAAc,cACZ;AACF;AAAA,MACF;AACA,oBAAc,SAAS;AACvB,iBAAW,OAAO,MAAM;AACtB,cAAM,KAAK,SAAS,cAAc,IAAI;AACtC,WAAG,YAAY;AACf,cAAM,MAAM,SAAS,cAAc,QAAQ;AAC3C,YAAI,OAAO;AACX,YAAI,YAAY;AAChB,YAAI,QAAQ,cAAc,IAAI;AAC9B,YAAI,cAAc,GAAG,IAAI,IAAI,KAAK,IAAI,WAAW;AACjD,WAAG,KAAK,SAAS,YAAY;AAC3B,qBAAW;AACX,cAAI,WAAW;AACf,cAAI;AACF,kBAAM,UAAU,MAAM,OAAO,uBAAuB;AAAA,cAClD,aAAa,IAAI;AAAA,YACnB,CAAC;AACD,qBAAS,UAAU,OAAO;AAAA,UAC5B,SAAS,KAAK;AACZ,gBAAI,WAAW;AACf,sBAAU,GAAY;AAAA,UACxB;AAAA,QACF,CAAC;AACD,WAAG,YAAY,GAAG;AAClB,oBAAY,YAAY,EAAE;AAAA,MAC5B;AACA,kBAAY,SAAS;AAAA,IACvB,SAAS,KAAK;AACZ,UAAI,UAAU,kBAAmB;AACjC,oBAAc,cAAe,IAAc;AAAA,IAC7C;AAAA,EACF;AAEA,QAAM,YAAY,CAAC,QAAwB;AACzC,WAAO,iBAA8B,cAAc,EAAE,QAAQ,CAAC,UAAU;AACtE,YAAM,SAAS,MAAM,QAAQ,UAAU;AAAA,IACzC,CAAC;AACD,WAAO,iBAA8B,YAAY,EAAE,QAAQ,CAAC,QAAQ;AAClE,UAAI,UAAU,OAAO,6BAA6B,IAAI,QAAQ,QAAQ,GAAG;AAAA,IAC3E,CAAC;AACD,eAAW;AACX,QAAI,QAAQ,UAAW,MAAK,mBAAmB;AAAA,EACjD;AAEA,YAAU,SAAS,cAAc,QAAQ;AAEzC,SAAO,iBAAoC,YAAY,EAAE,QAAQ,CAAC,QAAQ;AACxE,OAAG,KAAK,SAAS,MAAM,UAAU,IAAI,QAAQ,GAAe,CAAC;AAAA,EAC/D,CAAC;AAED,KAAG,EAAE,wBAAwB,GAAG,SAAS,MAAM,SAAS,WAAW,CAAC;AAGpE,QAAM,aAAa,EAAmB,sBAAsB;AAC5D,KAAG,YAAY,UAAU,OAAO,OAAO;AACrC,OAAG,eAAe;AAClB,eAAW;AACX,UAAM,aAAc,WAAW,SAAS,UAAU,YAAY,EAAuB;AACrF,QAAI;AACF,YAAM,EAAE,UAAU,IAAI,MAAM,OAAO,cAAc,UAAU;AAC3D,QAAe,mCAAmC,EAAE,cAAc;AAClE,aAAO,iBAA8B,cAAc,EAAE,QAAQ,CAAC,MAAM;AAClE,UAAE,SAAS,EAAE,QAAQ,UAAU;AAAA,MACjC,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,gBAAU,GAAY;AAAA,IACxB;AAAA,EACF,CAAC;AAED,KAAG,EAAE,6BAA6B,GAAG,SAAS,MAAM;AAClD,UAAM,SAAS,OAAO,iBAAiB;AACvC,QAAI,OAAQ,UAAS,UAAU,MAAM;AAAA,EACvC,CAAC;AAGD,QAAM,gBAAgB,EAAmB,yBAAyB;AAClE,KAAG,eAAe,UAAU,OAAO,OAAO;AACxC,OAAG,eAAe;AAClB,eAAW;AACX,UAAM,YACJ,cAAc,SAAS,UAAU,WAAW,EAC5C,MAAM,KAAK;AACb,UAAM,aACJ,cAAc,SAAS,UAAU,YAAY,EAC7C;AACF,QAAI;AACF,YAAM,UAAU,MAAM,OAAO,mBAAmB,WAAW,UAAU;AACrE,eAAS,UAAU,OAAO;AAAA,IAC5B,SAAS,KAAK;AACZ,gBAAU,GAAY;AAAA,IACxB;AAAA,EACF,CAAC;AAGD,KAAG,EAAE,iCAAiC,GAAG,SAAS,YAAY;AAC5D,eAAW;AACX,QAAI;AACF,YAAM,UAAU,MAAM,OAAO,mBAAmB;AAChD,eAAS,UAAU,OAAO;AAAA,IAC5B,SAAS,KAAK;AACZ,gBAAU,GAAY;AAAA,IACxB;AAAA,EACF,CAAC;AAID,QAAM,cAAc,OAAO;AAAA,IACzB;AAAA,EACF;AACA,MAAI,YAAa,IAAG,aAAa,SAAS,YAAY;AACpD,eAAW;AACX,UAAM,MAAM;AACZ,QAAI,WAAW;AACf,oBAAgB,IAAI,gBAAgB;AACpC,QAAI;AACF,YAAM,UAAU,MAAM,OAAO,kBAAkB;AAAA,QAC7C,QAAQ,cAAc;AAAA,QACtB,OAAO,SAAS;AAAA,MAClB,CAAC;AACD,eAAS,UAAU,OAAO;AAAA,IAC5B,SAAS,KAAK;AACZ,UAAI,WAAW;AAEf,UAAK,IAAc,SAAS,aAAc,WAAU,GAAY;AAAA,IAClE,UAAE;AACA,sBAAgB;AAAA,IAClB;AAAA,EACF,CAAC;AAGD,QAAM,aAAa,EAAmB,sBAAsB;AAC5D,KAAG,YAAY,UAAU,OAAO,OAAO;AACrC,OAAG,eAAe;AAClB,eAAW;AACX,UAAM,MACJ,WAAW,SAAS,UAAU,KAAK,EACnC,MAAM,KAAK;AACb,UAAM,QAAS,WAAW,SAAS,UAAU,OAAO,EAAuB,MACxE,MAAM,GAAG,EACT,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,OAAO;AACjB,QAAI;AACF,YAAM,UAAU,MAAM,OAAO,mBAAmB,KAAK;AAAA,QACnD,MAAM,SAAS;AAAA,QACf,iBAAiB,SAAS;AAAA,QAC1B,OAAO,MAAM,SAAS,IAAI,QAAQ;AAAA,MACpC,CAAC;AACD,eAAS,UAAU,OAAO;AAAA,IAC5B,SAAS,KAAK;AACZ,gBAAU,GAAY;AAAA,IACxB;AAAA,EACF,CAAC;AAGD,QAAM,SAAS,EAAmB,4BAA4B;AAC9D,QAAM,aAAa,EAAkB,iCAAiC;AACtE,QAAM,UAAU,EAAe,kCAAkC;AACjE,QAAM,YAAY,EAAkB,uCAAuC;AAC3E,QAAM,WAAW,EAAe,qCAAqC;AACrE,MAAI,YAAY;AAEhB,QAAM,oBAAoB,MAAY;AACpC,WAAO,SAAS;AAChB,eAAW,SAAS;AACpB,YAAQ,cAAc;AACtB,cAAU,YAAY;AACtB,aAAS,cAAc;AACvB;AAAA,EACF;AAEA,QAAM,uBAAuB,OAAO,QAA+B;AACjE,UAAM,QAAQ,EAAE;AAChB,QAAI;AACF,YAAM,MAAM,MAAM,cAAAA,QAAO,SAAS,KAAK;AAAA,QACrC,MAAM;AAAA,QACN,QAAQ;AAAA,QACR,sBAAsB;AAAA,MACxB,CAAC;AACD,UAAI,UAAU,UAAW,WAAU,YAAY;AAAA,IACjD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,KAAG,QAAQ,UAAU,OAAO,OAAO;AACjC,OAAG,eAAe;AAClB,eAAW;AACX,UAAM,SAAU,OAAO,SAAS,UAAU,QAAQ,EAAuB,MACtE,MAAM,GAAG,EACT,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,OAAO;AACjB,QAAI,OAAO,WAAW,GAAG;AACvB,gBAAU,iCAAiC;AAC3C;AAAA,IACF;AACA,UAAM,QAAS,OAAO,SAAS,UAAU,OAAO,EAAuB,MACpE,MAAM,GAAG,EACT,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,OAAO;AAEjB,WAAO,SAAS;AAChB,eAAW,SAAS;AACpB,wBAAoB,IAAI,gBAAgB;AACxC,QAAI;AACF,YAAM,UAAU,MAAM,OAAO,sBAAsB;AAAA,QACjD;AAAA,QACA,OAAO,MAAM,SAAS,IAAI,QAAQ;AAAA,QAClC,MAAM,SAAS;AAAA,QACf,iBAAiB,SAAS;AAAA,QAC1B,QAAQ,kBAAkB;AAAA,QAC1B,OAAO,CAAC,QAAQ;AACd,kBAAQ,cAAc;AACtB,eAAK,qBAAqB,GAAG;AAAA,QAC/B;AAAA,MACF,CAAC;AACD,eAAS,UAAU,OAAO;AAAA,IAC5B,SAAS,KAAK;AACZ,wBAAkB;AAClB,UAAK,IAAc,SAAS,aAAc,WAAU,GAAY;AAAA,IAClE,UAAE;AACA,0BAAoB;AAAA,IACtB;AAAA,EACF,CAAC;AAED,KAAG,EAAE,qCAAqC,GAAG,SAAS,MAAM;AAC1D,uBAAmB,MAAM;AACzB,sBAAkB;AAAA,EACpB,CAAC;AAED,SAAO;AAAA,IACL,QAAQ,MAAM;AACZ,yBAAmB,MAAM;AACzB,qBAAe,MAAM;AACrB,iBAAW,KAAK,UAAW,GAAE;AAAA,IAC/B;AAAA,IACA;AAAA,EACF;AACF;","names":["QRCode"]}
@@ -1,8 +1,8 @@
1
1
  import { AbstractSimplePool } from 'nostr-tools/abstract-pool';
2
- import { j as StoredAccount, R as RelayMismatchHandler, S as Signer } from '../signer-BepGdtJs.cjs';
2
+ import { n as StoredAccount, R as RelayMismatchHandler, S as Signer } from '../signer-oU50RBqS.cjs';
3
3
  import 'nostr-tools';
4
4
 
5
- type LoginTab = 'create' | 'ncryptsec' | 'extension' | 'bunker' | 'nostrconnect' | 'android';
5
+ type LoginTab = 'create' | 'ncryptsec' | 'extension' | 'bunker' | 'nostrconnect' | 'nip55web' | 'android';
6
6
  interface LoginUiHandlers {
7
7
  onLogin?: (account: StoredAccount) => void;
8
8
  onCancel?: () => void;
@@ -13,12 +13,29 @@ interface LoginUiHandlers {
13
13
  onRelayMismatch?: RelayMismatchHandler;
14
14
  /** Initial tab to show. Defaults to 'create'. */
15
15
  defaultTab?: LoginTab;
16
+ /**
17
+ * Optional diagnostic stream for the browser NIP-55 (intent/clipboard)
18
+ * flow. Wire it to an on-screen log when testing on a phone — the flow
19
+ * navigates away, so its behaviour is otherwise hard to observe.
20
+ */
21
+ onNip55WebDebug?: (message: string) => void;
16
22
  }
17
23
  interface LoginUiBinding {
18
24
  detach(): void;
19
25
  selectTab(tab: LoginTab): void;
20
26
  }
21
- declare function renderLoginHtml(): string;
27
+ interface RenderLoginOptions {
28
+ /**
29
+ * Include the "Signer app" tab (browser NIP-55). Defaults to `true`.
30
+ *
31
+ * Pass `signer.supportsNip55Web()` to hide it where it cannot work —
32
+ * notably inside a Capacitor native shell, where
33
+ * `loginWithAndroidSigner` is the right path. Omit it in a native build
34
+ * and the tab would only ever error.
35
+ */
36
+ includeNip55Web?: boolean;
37
+ }
38
+ declare function renderLoginHtml(options?: RenderLoginOptions): string;
22
39
  declare function attachLoginListeners(rootEl: HTMLElement, signer: Signer, handlers?: LoginUiHandlers): LoginUiBinding;
23
40
 
24
- export { type LoginTab, type LoginUiBinding, type LoginUiHandlers, attachLoginListeners, renderLoginHtml };
41
+ export { type LoginTab, type LoginUiBinding, type LoginUiHandlers, type RenderLoginOptions, attachLoginListeners, renderLoginHtml };
@@ -1,8 +1,8 @@
1
1
  import { AbstractSimplePool } from 'nostr-tools/abstract-pool';
2
- import { j as StoredAccount, R as RelayMismatchHandler, S as Signer } from '../signer-BepGdtJs.js';
2
+ import { n as StoredAccount, R as RelayMismatchHandler, S as Signer } from '../signer-oU50RBqS.js';
3
3
  import 'nostr-tools';
4
4
 
5
- type LoginTab = 'create' | 'ncryptsec' | 'extension' | 'bunker' | 'nostrconnect' | 'android';
5
+ type LoginTab = 'create' | 'ncryptsec' | 'extension' | 'bunker' | 'nostrconnect' | 'nip55web' | 'android';
6
6
  interface LoginUiHandlers {
7
7
  onLogin?: (account: StoredAccount) => void;
8
8
  onCancel?: () => void;
@@ -13,12 +13,29 @@ interface LoginUiHandlers {
13
13
  onRelayMismatch?: RelayMismatchHandler;
14
14
  /** Initial tab to show. Defaults to 'create'. */
15
15
  defaultTab?: LoginTab;
16
+ /**
17
+ * Optional diagnostic stream for the browser NIP-55 (intent/clipboard)
18
+ * flow. Wire it to an on-screen log when testing on a phone — the flow
19
+ * navigates away, so its behaviour is otherwise hard to observe.
20
+ */
21
+ onNip55WebDebug?: (message: string) => void;
16
22
  }
17
23
  interface LoginUiBinding {
18
24
  detach(): void;
19
25
  selectTab(tab: LoginTab): void;
20
26
  }
21
- declare function renderLoginHtml(): string;
27
+ interface RenderLoginOptions {
28
+ /**
29
+ * Include the "Signer app" tab (browser NIP-55). Defaults to `true`.
30
+ *
31
+ * Pass `signer.supportsNip55Web()` to hide it where it cannot work —
32
+ * notably inside a Capacitor native shell, where
33
+ * `loginWithAndroidSigner` is the right path. Omit it in a native build
34
+ * and the tab would only ever error.
35
+ */
36
+ includeNip55Web?: boolean;
37
+ }
38
+ declare function renderLoginHtml(options?: RenderLoginOptions): string;
22
39
  declare function attachLoginListeners(rootEl: HTMLElement, signer: Signer, handlers?: LoginUiHandlers): LoginUiBinding;
23
40
 
24
- export { type LoginTab, type LoginUiBinding, type LoginUiHandlers, attachLoginListeners, renderLoginHtml };
41
+ export { type LoginTab, type LoginUiBinding, type LoginUiHandlers, type RenderLoginOptions, attachLoginListeners, renderLoginHtml };
package/dist/ui/index.js CHANGED
@@ -6,10 +6,12 @@ var TABS = [
6
6
  { id: "extension", label: "Extension" },
7
7
  { id: "bunker", label: "Bunker URI" },
8
8
  { id: "nostrconnect", label: "Remote (QR)" },
9
+ { id: "nip55web", label: "Signer app" },
9
10
  { id: "android", label: "Android" }
10
11
  ];
11
- function renderLoginHtml() {
12
- const tabs = TABS.map(
12
+ function renderLoginHtml(options = {}) {
13
+ const includeNip55Web = options.includeNip55Web ?? true;
14
+ const tabs = TABS.filter((t) => t.id !== "nip55web" || includeNip55Web).map(
13
15
  (t) => `<button class="nostr-signer__tab nostr-signer__tab--${t.id}" type="button" data-tab="${t.id}">${t.label}</button>`
14
16
  ).join("");
15
17
  return `<div class="nostr-signer__root">
@@ -66,6 +68,10 @@ function renderLoginHtml() {
66
68
  <button class="nostr-signer__button nostr-signer__button--secondary" type="button" data-action="nostrconnect-cancel">Cancel</button>
67
69
  </div>
68
70
  </section>
71
+ ${includeNip55Web ? `<section class="nostr-signer__panel nostr-signer__panel--nip55web" data-panel="nip55web" hidden>
72
+ <p class="nostr-signer__hint">Sign in with a signer app installed on this Android device, using the browser (NIP-55). You&rsquo;ll be asked to approve each signature in the app.</p>
73
+ <button class="nostr-signer__button nostr-signer__button--primary" type="button" data-action="nip55web-login">Open signer app</button>
74
+ </section>` : ""}
69
75
  <section class="nostr-signer__panel nostr-signer__panel--android" data-panel="android" hidden>
70
76
  <p class="nostr-signer__hint">Sign in with an Android external signer app (NIP-55) such as Amber.</p>
71
77
  <p class="nostr-signer__status" data-region="android-status">Loading installed signers&hellip;</p>
@@ -84,6 +90,7 @@ function renderLoginHtml() {
84
90
  function attachLoginListeners(rootEl, signer, handlers = {}) {
85
91
  const detachers = [];
86
92
  let nostrConnectAbort = null;
93
+ let nip55WebAbort = null;
87
94
  const on = (el, event, handler) => {
88
95
  const wrapped = handler;
89
96
  el.addEventListener(event, wrapped);
@@ -204,6 +211,27 @@ function attachLoginListeners(rootEl, signer, handlers = {}) {
204
211
  showError(err);
205
212
  }
206
213
  });
214
+ const nip55WebBtn = rootEl.querySelector(
215
+ '[data-action="nip55web-login"]'
216
+ );
217
+ if (nip55WebBtn) on(nip55WebBtn, "click", async () => {
218
+ clearError();
219
+ const btn = nip55WebBtn;
220
+ btn.disabled = true;
221
+ nip55WebAbort = new AbortController();
222
+ try {
223
+ const account = await signer.loginWithNip55Web({
224
+ signal: nip55WebAbort.signal,
225
+ debug: handlers.onNip55WebDebug
226
+ });
227
+ handlers.onLogin?.(account);
228
+ } catch (err) {
229
+ btn.disabled = false;
230
+ if (err.name !== "AbortError") showError(err);
231
+ } finally {
232
+ nip55WebAbort = null;
233
+ }
234
+ });
207
235
  const bunkerForm = q('[data-form="bunker"]');
208
236
  on(bunkerForm, "submit", async (ev) => {
209
237
  ev.preventDefault();
@@ -286,6 +314,7 @@ function attachLoginListeners(rootEl, signer, handlers = {}) {
286
314
  return {
287
315
  detach: () => {
288
316
  nostrConnectAbort?.abort();
317
+ nip55WebAbort?.abort();
289
318
  for (const d of detachers) d();
290
319
  },
291
320
  selectTab