BunkerView.svelte raw
1 <script>
2 import { createEventDispatcher, onMount } from "svelte";
3 import QRCode from "qrcode";
4 import { getBunkerInfo } from "./api.js";
5 import { bytesToHex } from "@noble/hashes/utils";
6 import {
7 bunkerServiceActive,
8 bunkerConnectedClients,
9 configureBunkerWorker,
10 connectBunkerWorker,
11 requestBunkerStatus,
12 resetBunkerState
13 } from "./stores.js";
14
15 export let isLoggedIn = false;
16 export let userPubkey = "";
17 export let userSigner = null;
18 export let userPrivkey = null; // User's private key for signing (Uint8Array)
19 export let currentEffectiveRole = "";
20
21 const dispatch = createEventDispatcher();
22
23 // Local UI state
24 let bunkerInfo = null;
25 let isLoading = false;
26 let error = "";
27 let clientQrDataUrl = "";
28 let signerQrDataUrl = "";
29 let copiedItem = "";
30 let bunkerSecret = "";
31 let isStartingService = false;
32
33 // Subscribe to global bunker stores
34 $: isServiceActive = $bunkerServiceActive;
35 $: connectedClients = $bunkerConnectedClients;
36
37 $: canAccess = isLoggedIn && userPubkey && (
38 currentEffectiveRole === "write" ||
39 currentEffectiveRole === "admin" ||
40 currentEffectiveRole === "owner"
41 );
42
43 // Generate bunker URLs when bunkerInfo and userPubkey are available
44 $: clientBunkerURL = bunkerInfo && userPubkey ?
45 `bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}` : "";
46
47 $: signerBunkerURL = bunkerInfo ?
48 `nostr+connect://${bunkerInfo.relay_url}` : "";
49
50 onMount(async () => {
51 await loadBunkerInfo();
52 // Request current status from worker (in case it's already running)
53 requestBunkerStatus();
54 });
55
56 // Note: No onDestroy cleanup - worker persists across component mounts
57
58 // Start the bunker service (via Web Worker)
59 async function startBunkerService() {
60 // Prevent starting if already active or starting
61 if (isServiceActive || isStartingService) {
62 console.log("Service already active or starting, ignoring");
63 return;
64 }
65
66 if (!userPrivkey || !userPubkey || !bunkerInfo) {
67 error = "Missing private key or bunker info";
68 return;
69 }
70
71 isStartingService = true;
72 error = "";
73
74 try {
75 // Configure the worker with user credentials
76 const privkeyHex = userPrivkey instanceof Uint8Array ? bytesToHex(userPrivkey) : userPrivkey;
77 configureBunkerWorker({
78 userPubkey,
79 userPrivkey: privkeyHex,
80 relayUrl: bunkerInfo.relay_url,
81 secrets: bunkerSecret ? [bunkerSecret] : []
82 });
83
84 // Connect the worker
85 connectBunkerWorker();
86
87 // Regenerate QR codes
88 await generateQRCodes();
89
90 console.log("Bunker worker started successfully");
91 } catch (err) {
92 console.error("Failed to start bunker service:", err);
93 error = err.message || "Failed to start bunker service";
94 resetBunkerState();
95 } finally {
96 isStartingService = false;
97 }
98 }
99
100 // Stop the bunker service (via Web Worker)
101 function stopBunkerService() {
102 resetBunkerState();
103 generateQRCodes();
104 }
105
106 async function loadBunkerInfo() {
107 isLoading = true;
108 error = "";
109
110 try {
111 bunkerInfo = await getBunkerInfo();
112
113 // Generate a random secret for secure connection
114 if (!bunkerSecret) {
115 bunkerSecret = generateSecret();
116 }
117
118 // Generate QR codes
119 await generateQRCodes();
120 } catch (err) {
121 console.error("Error loading bunker info:", err);
122 error = err.message || "Failed to load bunker information";
123 } finally {
124 isLoading = false;
125 }
126 }
127
128 function generateSecret() {
129 const array = new Uint8Array(16);
130 crypto.getRandomValues(array);
131 return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
132 }
133
134 async function regenerateSecret() {
135 bunkerSecret = generateSecret();
136 await generateQRCodes();
137 }
138
139 async function generateQRCodes() {
140 if (clientBunkerURL) {
141 clientQrDataUrl = await QRCode.toDataURL(clientBunkerURL, {
142 width: 280,
143 margin: 2,
144 color: { dark: "#000000", light: "#ffffff" }
145 });
146 }
147
148 if (signerBunkerURL) {
149 signerQrDataUrl = await QRCode.toDataURL(signerBunkerURL, {
150 width: 280,
151 margin: 2,
152 color: { dark: "#000000", light: "#ffffff" }
153 });
154 }
155 }
156
157 // Regenerate QR codes when URLs change
158 $: if (clientBunkerURL || signerBunkerURL) {
159 generateQRCodes();
160 }
161
162 function copyToClipboard(text, label) {
163 navigator.clipboard.writeText(text);
164 copiedItem = label;
165 setTimeout(() => {
166 copiedItem = "";
167 }, 2000);
168 }
169
170 function openLoginModal() {
171 dispatch("openLoginModal");
172 }
173 </script>
174
175 {#if !bunkerInfo?.available}
176 <div class="bunker-view">
177 <div class="unavailable-message">
178 <h3>Remote Signing Not Available</h3>
179 <p>This relay does not have bunker mode enabled, or ACL mode is set to "none".</p>
180 <p class="hint">Remote signing requires the relay operator to enable ACL mode "follows" or "managed".</p>
181 </div>
182 </div>
183 {:else if canAccess}
184 <div class="bunker-view">
185 <div class="header-section">
186 <h3>Remote Signing (NIP-46 Bunker)</h3>
187 <button class="refresh-btn" on:click={loadBunkerInfo} disabled={isLoading}>
188 {isLoading ? "Loading..." : "Refresh"}
189 </button>
190 </div>
191
192 {#if error}
193 <div class="error-message">{error}</div>
194 {/if}
195
196 {#if isLoading && !bunkerInfo}
197 <div class="loading">Loading bunker information...</div>
198 {:else if bunkerInfo}
199 <div class="instructions">
200 <p><strong>How it works:</strong> Start the bunker service to allow remote apps (like Smesh) to request signatures from your ORLY account.
201 Share the QR code or bunker URL with your client app.</p>
202 </div>
203
204 <!-- Service Control -->
205 <div class="service-control">
206 <div class="service-header">
207 <h4>Bunker Service</h4>
208 <div class="service-status" class:active={isServiceActive}>
209 <span class="status-dot"></span>
210 {isServiceActive ? 'Active' : 'Inactive'}
211 </div>
212 </div>
213
214 {#if !userPrivkey}
215 <div class="no-privkey-warning">
216 Bunker service requires nsec login. Please log in with your private key to enable remote signing.
217 </div>
218 {:else}
219 <div class="service-actions">
220 {#if isServiceActive}
221 <button class="stop-btn" on:click={stopBunkerService}>
222 Stop Service
223 </button>
224 {:else}
225 <button class="start-btn" on:click={startBunkerService} disabled={isStartingService}>
226 {isStartingService ? 'Starting...' : 'Start Service'}
227 </button>
228 {/if}
229 </div>
230
231 {#if isServiceActive && connectedClients.length > 0}
232 <div class="connected-clients">
233 <h5>Connected Clients ({connectedClients.length})</h5>
234 {#each connectedClients as client}
235 <div class="client-entry">
236 <code>{client.pubkey.substring(0, 16)}...</code>
237 <span class="client-time">Connected {new Date(client.connectedAt).toLocaleTimeString()}</span>
238 </div>
239 {/each}
240 </div>
241 {/if}
242
243 {/if}
244 </div>
245
246 <!-- Connection Info -->
247 <div class="connection-info">
248 <h4>Connection Details</h4>
249 <div class="info-row">
250 <span class="label">Relay:</span>
251 <code>{bunkerInfo.relay_url}</code>
252 <button class="copy-btn" on:click={() => copyToClipboard(bunkerInfo.relay_url, "relay")}>
253 {copiedItem === "relay" ? "Copied!" : "Copy"}
254 </button>
255 </div>
256 <div class="info-row">
257 <span class="label">Your npub:</span>
258 <code class="npub">{userPubkey}</code>
259 </div>
260 <div class="info-row">
261 <span class="label">Secret:</span>
262 <code class="secret">{bunkerSecret}</code>
263 <button class="copy-btn" on:click={regenerateSecret}>Regenerate</button>
264 </div>
265 </div>
266 {/if}
267 </div>
268 {:else if isLoggedIn}
269 <div class="bunker-view">
270 <div class="access-denied">
271 <h3>Access Denied</h3>
272 <p>You need write access to use remote signing. Your current access level: <strong>{currentEffectiveRole || "read-only"}</strong></p>
273 </div>
274 </div>
275 {:else}
276 <div class="login-prompt">
277 <p>Please log in to access remote signing.</p>
278 <button class="login-btn" on:click={openLoginModal}>Log In</button>
279 </div>
280 {/if}
281
282 <style>
283 .bunker-view {
284 padding: 1em;
285 box-sizing: border-box;
286 }
287
288 .header-section {
289 display: flex;
290 justify-content: space-between;
291 align-items: center;
292 margin-bottom: 1em;
293 }
294
295 .header-section h3 {
296 margin: 0;
297 color: var(--text-color);
298 }
299
300 .refresh-btn {
301 background-color: var(--primary);
302 color: var(--text-color);
303 border: none;
304 padding: 0.5em 1em;
305 border-radius: 4px;
306 cursor: pointer;
307 font-size: 0.9em;
308 }
309
310 .refresh-btn:hover:not(:disabled) {
311 background-color: var(--accent-hover-color);
312 }
313
314 .refresh-btn:disabled {
315 opacity: 0.6;
316 cursor: not-allowed;
317 }
318
319 .error-message {
320 background-color: var(--warning);
321 color: var(--text-color);
322 padding: 0.75em 1em;
323 border-radius: 4px;
324 margin-bottom: 1em;
325 }
326
327 .loading {
328 text-align: center;
329 padding: 2em;
330 color: var(--text-color);
331 opacity: 0.7;
332 }
333
334 .instructions {
335 background-color: var(--card-bg);
336 padding: 1em;
337 border-radius: 6px;
338 margin-bottom: 1.5em;
339 }
340
341 .instructions p {
342 margin: 0;
343 color: var(--text-color);
344 }
345
346 /* Service Control Styles */
347 .service-control {
348 background-color: var(--card-bg);
349 padding: 1.25em;
350 border-radius: 8px;
351 margin-bottom: 1.5em;
352 }
353
354 .service-header {
355 display: flex;
356 justify-content: space-between;
357 align-items: center;
358 margin-bottom: 1em;
359 }
360
361 .service-header h4 {
362 margin: 0;
363 color: var(--text-color);
364 }
365
366 .service-status {
367 display: flex;
368 align-items: center;
369 gap: 0.5em;
370 font-size: 0.9em;
371 color: var(--text-color);
372 opacity: 0.7;
373 }
374
375 .service-status.active {
376 opacity: 1;
377 color: #4ade80;
378 }
379
380 .status-dot {
381 width: 10px;
382 height: 10px;
383 border-radius: 50%;
384 background-color: #6b7280;
385 }
386
387 .service-status.active .status-dot {
388 background-color: #4ade80;
389 box-shadow: 0 0 8px rgba(74, 222, 128, 0.5);
390 }
391
392 .service-actions {
393 margin-bottom: 1em;
394 }
395
396 .start-btn, .stop-btn {
397 padding: 0.75em 1.5em;
398 border: none;
399 border-radius: 6px;
400 font-size: 1em;
401 font-weight: 500;
402 cursor: pointer;
403 transition: background-color 0.2s;
404 }
405
406 .start-btn {
407 background-color: #4ade80;
408 color: #0a0a0a;
409 }
410
411 .start-btn:hover:not(:disabled) {
412 background-color: #22c55e;
413 }
414
415 .start-btn:disabled {
416 opacity: 0.6;
417 cursor: not-allowed;
418 }
419
420 .stop-btn {
421 background-color: #ef4444;
422 color: white;
423 }
424
425 .stop-btn:hover {
426 background-color: #dc2626;
427 }
428
429 .no-privkey-warning {
430 background-color: rgba(255, 193, 7, 0.15);
431 border: 1px solid rgba(255, 193, 7, 0.5);
432 color: var(--text-color);
433 padding: 0.75em 1em;
434 border-radius: 4px;
435 font-size: 0.95em;
436 }
437
438 .connected-clients {
439 margin-top: 1em;
440 padding-top: 1em;
441 border-top: 1px solid var(--border-color);
442 }
443
444 .connected-clients h5 {
445 margin: 0 0 0.5em 0;
446 color: var(--text-color);
447 font-size: 0.9em;
448 }
449
450 .client-entry {
451 display: flex;
452 justify-content: space-between;
453 align-items: center;
454 padding: 0.5em;
455 background-color: var(--bg-color);
456 border-radius: 4px;
457 margin-bottom: 0.5em;
458 }
459
460 .client-entry code {
461 font-size: 0.85em;
462 }
463
464 .client-time {
465 font-size: 0.8em;
466 opacity: 0.7;
467 }
468
469 .qr-sections {
470 display: grid;
471 grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
472 gap: 1.5em;
473 margin-bottom: 1.5em;
474 }
475
476 .qr-section {
477 background-color: var(--card-bg);
478 padding: 1.25em;
479 border-radius: 8px;
480 }
481
482 .qr-section h4 {
483 margin: 0 0 0.5em 0;
484 color: var(--text-color);
485 }
486
487 .section-desc {
488 margin: 0 0 1em 0;
489 color: var(--text-color);
490 opacity: 0.8;
491 font-size: 0.95em;
492 }
493
494 .qr-container {
495 display: flex;
496 justify-content: center;
497 margin: 1em 0;
498 position: relative;
499 }
500
501 .qr-container.clickable {
502 cursor: pointer;
503 transition: transform 0.1s;
504 }
505
506 .qr-container.clickable:hover {
507 transform: scale(1.02);
508 }
509
510 .qr-container.clickable:active {
511 transform: scale(0.98);
512 }
513
514 .qr-code {
515 border-radius: 8px;
516 background: white;
517 padding: 8px;
518 }
519
520 .qr-overlay {
521 position: absolute;
522 top: 50%;
523 left: 50%;
524 transform: translate(-50%, -50%);
525 background-color: rgba(0, 0, 0, 0.85);
526 color: #4ade80;
527 padding: 0.75em 1.5em;
528 border-radius: 8px;
529 font-weight: 600;
530 font-size: 1.1em;
531 opacity: 0;
532 transition: opacity 0.2s;
533 pointer-events: none;
534 }
535
536 .qr-overlay.visible {
537 opacity: 1;
538 }
539
540 .qr-placeholder {
541 width: 280px;
542 height: 280px;
543 display: flex;
544 align-items: center;
545 justify-content: center;
546 background-color: var(--bg-color);
547 border-radius: 8px;
548 color: var(--text-color);
549 opacity: 0.5;
550 }
551
552 .url-display {
553 text-align: center;
554 margin-top: 0.5em;
555 }
556
557 .bunker-url {
558 font-family: monospace;
559 font-size: 0.75em;
560 word-break: break-all;
561 padding: 0.5em;
562 background-color: var(--bg-color);
563 border-radius: 4px;
564 display: inline-block;
565 max-width: 100%;
566 color: var(--text-color);
567 }
568
569 .copy-hint {
570 text-align: center;
571 font-size: 0.8em;
572 color: var(--text-color);
573 opacity: 0.6;
574 margin-top: 0.5em;
575 }
576
577 .connection-info {
578 background-color: var(--card-bg);
579 padding: 1.25em;
580 border-radius: 8px;
581 margin-bottom: 1.5em;
582 }
583
584 .connection-info h4 {
585 margin: 0 0 1em 0;
586 color: var(--text-color);
587 }
588
589 .info-row {
590 display: flex;
591 align-items: center;
592 gap: 0.5em;
593 margin-bottom: 0.75em;
594 flex-wrap: wrap;
595 }
596
597 .info-row:last-child {
598 margin-bottom: 0;
599 }
600
601 .label {
602 color: var(--text-color);
603 opacity: 0.7;
604 min-width: 80px;
605 }
606
607 code {
608 font-family: monospace;
609 padding: 0.25em 0.5em;
610 background-color: var(--bg-color);
611 border-radius: 4px;
612 color: var(--text-color);
613 word-break: break-all;
614 }
615
616 .npub, .secret {
617 font-size: 0.85em;
618 }
619
620 .copy-btn {
621 padding: 0.3em 0.6em;
622 background-color: var(--primary);
623 color: var(--text-color);
624 border: none;
625 border-radius: 4px;
626 cursor: pointer;
627 font-size: 0.8em;
628 }
629
630 .copy-btn:hover {
631 background-color: var(--accent-hover-color);
632 }
633
634 .unavailable-message, .access-denied {
635 text-align: center;
636 padding: 2em;
637 background-color: var(--card-bg);
638 border-radius: 8px;
639 }
640
641 .unavailable-message h3, .access-denied h3 {
642 margin: 0 0 0.5em 0;
643 color: var(--text-color);
644 }
645
646 .unavailable-message p, .access-denied p {
647 margin: 0.5em 0;
648 color: var(--text-color);
649 opacity: 0.8;
650 }
651
652 .hint {
653 font-size: 0.9em;
654 opacity: 0.6 !important;
655 }
656
657 .login-prompt {
658 text-align: center;
659 padding: 2em;
660 background-color: var(--card-bg);
661 border-radius: 8px;
662 border: 1px solid var(--border-color);
663 max-width: 32em;
664 margin: 1em;
665 }
666
667 .login-prompt p {
668 margin: 0 0 1.5rem 0;
669 color: var(--text-color);
670 font-size: 1.1rem;
671 }
672
673 .login-btn {
674 background-color: var(--primary);
675 color: var(--text-color);
676 border: none;
677 padding: 0.75em 1.5em;
678 border-radius: 4px;
679 cursor: pointer;
680 font-weight: bold;
681 font-size: 0.9em;
682 }
683
684 .login-btn:hover {
685 background-color: var(--accent-hover-color);
686 }
687
688 @media (max-width: 600px) {
689 .qr-sections {
690 grid-template-columns: 1fr;
691 }
692
693 .bunker-url {
694 font-size: 0.65em;
695 }
696
697 .info-row {
698 flex-direction: column;
699 align-items: flex-start;
700 }
701 }
702 </style>
703