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