6594 lines
225 KiB
JavaScript
6594 lines
225 KiB
JavaScript
/**
|
||
* NOSTR_LOGIN_LITE - Authentication Library
|
||
*
|
||
* ⚠️ WARNING: THIS FILE IS AUTO-GENERATED - DO NOT EDIT MANUALLY!
|
||
* ⚠️ To make changes, edit lite/build.js and run: cd lite && node build.js
|
||
* ⚠️ Any manual edits to this file will be OVERWRITTEN when build.js runs!
|
||
*
|
||
* Two-file architecture:
|
||
* 1. Load nostr.bundle.js (official nostr-tools bundle)
|
||
* 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes)
|
||
* Generated on: 2026-07-21T23:30:02.044Z
|
||
*/
|
||
|
||
// Verify dependencies are loaded
|
||
if (typeof window !== 'undefined') {
|
||
if (!window.NostrTools) {
|
||
console.error('NOSTR_LOGIN_LITE: nostr.bundle.js must be loaded first');
|
||
throw new Error('Missing dependency: nostr.bundle.js');
|
||
}
|
||
|
||
// console.log('NOSTR_LOGIN_LITE: Dependencies verified ✓');
|
||
// console.log('NOSTR_LOGIN_LITE: NostrTools available with keys:', Object.keys(window.NostrTools));
|
||
// console.log('NOSTR_LOGIN_LITE: NIP-06 available:', !!window.NostrTools.nip06);
|
||
// console.log('NOSTR_LOGIN_LITE: NIP-46 available:', !!window.NostrTools.nip46);
|
||
}
|
||
|
||
// ======================================
|
||
// NOSTR_LOGIN_LITE Components
|
||
// ======================================
|
||
|
||
// ======================================
|
||
// CSS-Only Theme System
|
||
// ======================================
|
||
|
||
const THEME_CSS = {
|
||
'default': `/**
|
||
* NOSTR_LOGIN_LITE - Default Monospace Theme
|
||
* Black/white/red color scheme with monospace typography
|
||
* Simplified 14-variable system (6 core + 8 floating tab)
|
||
*/
|
||
|
||
:root {
|
||
/* Core Variables (6) */
|
||
--nl-primary-color: #000000;
|
||
--nl-secondary-color: #ffffff;
|
||
--nl-accent-color: #ff0000;
|
||
--nl-muted-color: #CCCCCC;
|
||
--nl-font-family: "Courier New", Courier, monospace;
|
||
--nl-border-radius: 15px;
|
||
--nl-border-width: 3px;
|
||
|
||
/* Floating Tab Variables (8) */
|
||
--nl-tab-bg-logged-out: #ffffff;
|
||
--nl-tab-bg-logged-in: #ffffff;
|
||
--nl-tab-bg-opacity-logged-out: 0.9;
|
||
--nl-tab-bg-opacity-logged-in: 0.2;
|
||
--nl-tab-color-logged-out: #000000;
|
||
--nl-tab-color-logged-in: #ffffff;
|
||
--nl-tab-border-logged-out: #000000;
|
||
--nl-tab-border-logged-in: #ff0000;
|
||
--nl-tab-border-opacity-logged-out: 1.0;
|
||
--nl-tab-border-opacity-logged-in: 0.1;
|
||
}
|
||
|
||
/* Base component styles using simplified variables */
|
||
.nl-component {
|
||
font-family: var(--nl-font-family);
|
||
color: var(--nl-primary-color);
|
||
}
|
||
|
||
.nl-button {
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
font-family: var(--nl-font-family);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.nl-button:hover {
|
||
border-color: var(--nl-accent-color);
|
||
}
|
||
|
||
.nl-button:active {
|
||
background: var(--nl-accent-color);
|
||
color: var(--nl-secondary-color);
|
||
}
|
||
|
||
.nl-input {
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
font-family: var(--nl-font-family);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.nl-input:focus {
|
||
border-color: var(--nl-accent-color);
|
||
outline: none;
|
||
}
|
||
|
||
.nl-container {
|
||
background: var(--nl-secondary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
}
|
||
|
||
.nl-title, .nl-heading {
|
||
font-family: var(--nl-font-family);
|
||
color: var(--nl-primary-color);
|
||
margin: 0;
|
||
}
|
||
|
||
.nl-text {
|
||
font-family: var(--nl-font-family);
|
||
color: var(--nl-primary-color);
|
||
}
|
||
|
||
.nl-text--muted {
|
||
color: var(--nl-muted-color);
|
||
}
|
||
|
||
.nl-icon {
|
||
font-family: var(--nl-font-family);
|
||
color: var(--nl-primary-color);
|
||
}
|
||
|
||
/* Floating tab styles */
|
||
.nl-floating-tab {
|
||
font-family: var(--nl-font-family);
|
||
border-radius: var(--nl-border-radius);
|
||
border: var(--nl-border-width) solid;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.nl-floating-tab--logged-out {
|
||
background: rgba(255, 255, 255, var(--nl-tab-bg-opacity-logged-out));
|
||
color: var(--nl-tab-color-logged-out);
|
||
border-color: rgba(0, 0, 0, var(--nl-tab-border-opacity-logged-out));
|
||
}
|
||
|
||
.nl-floating-tab--logged-in {
|
||
background: rgba(0, 0, 0, var(--nl-tab-bg-opacity-logged-in));
|
||
color: var(--nl-tab-color-logged-in);
|
||
border-color: rgba(255, 0, 0, var(--nl-tab-border-opacity-logged-in));
|
||
}
|
||
|
||
.nl-transition {
|
||
transition: all 0.2s ease;
|
||
}`,
|
||
'dark': `/**
|
||
* NOSTR_LOGIN_LITE - Dark Monospace Theme
|
||
*/
|
||
|
||
:root {
|
||
/* Core Variables (6) */
|
||
--nl-primary-color: #white;
|
||
--nl-secondary-color: #black;
|
||
--nl-accent-color: #ff0000;
|
||
--nl-muted-color: #666666;
|
||
--nl-font-family: "Courier New", Courier, monospace;
|
||
--nl-border-radius: 15px;
|
||
--nl-border-width: 3px;
|
||
|
||
/* Floating Tab Variables (8) */
|
||
--nl-tab-bg-logged-out: #ffffff;
|
||
--nl-tab-bg-logged-in: #000000;
|
||
--nl-tab-bg-opacity-logged-out: 0.9;
|
||
--nl-tab-bg-opacity-logged-in: 0.8;
|
||
--nl-tab-color-logged-out: #000000;
|
||
--nl-tab-color-logged-in: #ffffff;
|
||
--nl-tab-border-logged-out: #000000;
|
||
--nl-tab-border-logged-in: #ff0000;
|
||
--nl-tab-border-opacity-logged-out: 1.0;
|
||
--nl-tab-border-opacity-logged-in: 0.9;
|
||
}
|
||
|
||
/* Base component styles using simplified variables */
|
||
.nl-component {
|
||
font-family: var(--nl-font-family);
|
||
color: var(--nl-primary-color);
|
||
}
|
||
|
||
.nl-button {
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
font-family: var(--nl-font-family);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.nl-button:hover {
|
||
border-color: var(--nl-accent-color);
|
||
}
|
||
|
||
.nl-button:active {
|
||
background: var(--nl-accent-color);
|
||
color: var(--nl-secondary-color);
|
||
}
|
||
|
||
.nl-input {
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
font-family: var(--nl-font-family);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.nl-input:focus {
|
||
border-color: var(--nl-accent-color);
|
||
outline: none;
|
||
}
|
||
|
||
.nl-container {
|
||
background: var(--nl-secondary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
}
|
||
|
||
.nl-title, .nl-heading {
|
||
font-family: var(--nl-font-family);
|
||
color: var(--nl-primary-color);
|
||
margin: 0;
|
||
}
|
||
|
||
.nl-text {
|
||
font-family: var(--nl-font-family);
|
||
color: var(--nl-primary-color);
|
||
}
|
||
|
||
.nl-text--muted {
|
||
color: var(--nl-muted-color);
|
||
}
|
||
|
||
.nl-icon {
|
||
font-family: var(--nl-font-family);
|
||
color: var(--nl-primary-color);
|
||
}
|
||
|
||
/* Floating tab styles */
|
||
.nl-floating-tab {
|
||
font-family: var(--nl-font-family);
|
||
border-radius: var(--nl-border-radius);
|
||
border: var(--nl-border-width) solid;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.nl-floating-tab--logged-out {
|
||
background: rgba(255, 255, 255, var(--nl-tab-bg-opacity-logged-out));
|
||
color: var(--nl-tab-color-logged-out);
|
||
border-color: rgba(0, 0, 0, var(--nl-tab-border-opacity-logged-out));
|
||
}
|
||
|
||
.nl-floating-tab--logged-in {
|
||
background: rgba(0, 0, 0, var(--nl-tab-bg-opacity-logged-in));
|
||
color: var(--nl-tab-color-logged-in);
|
||
border-color: rgba(255, 0, 0, var(--nl-tab-border-opacity-logged-in));
|
||
}
|
||
|
||
.nl-transition {
|
||
transition: all 0.2s ease;
|
||
}`
|
||
};
|
||
|
||
// Theme management functions
|
||
function injectThemeCSS(themeName = 'default') {
|
||
if (typeof document !== 'undefined') {
|
||
// Remove existing theme CSS
|
||
const existingStyle = document.getElementById('nl-theme-css');
|
||
if (existingStyle) {
|
||
existingStyle.remove();
|
||
}
|
||
|
||
// Inject selected theme CSS
|
||
const themeCss = THEME_CSS[themeName] || THEME_CSS['default'];
|
||
const style = document.createElement('style');
|
||
style.id = 'nl-theme-css';
|
||
style.textContent = themeCss;
|
||
document.head.appendChild(style);
|
||
// console.log('NOSTR_LOGIN_LITE: ' + themeName + ' theme CSS injected');
|
||
}
|
||
}
|
||
|
||
// Auto-inject default theme when DOM is ready
|
||
if (typeof document !== 'undefined') {
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => injectThemeCSS('default'));
|
||
} else {
|
||
injectThemeCSS('default');
|
||
}
|
||
}
|
||
|
||
// ======================================
|
||
// Modal UI Component
|
||
// ======================================
|
||
|
||
|
||
class Modal {
|
||
constructor(options = {}) {
|
||
this.options = options;
|
||
this.container = null;
|
||
this.isVisible = false;
|
||
this.currentScreen = null;
|
||
this.isEmbedded = !!options.embedded;
|
||
this.embeddedContainer = options.embedded;
|
||
|
||
// Initialize modal container and styles
|
||
this._initModal();
|
||
}
|
||
|
||
_initModal() {
|
||
// Create modal container
|
||
this.container = document.createElement('div');
|
||
this.container.id = this.isEmbedded ? 'nl-modal-embedded' : 'nl-modal';
|
||
|
||
if (this.isEmbedded) {
|
||
// Embedded mode: inline positioning, no overlay
|
||
this.container.style.cssText = `
|
||
position: relative;
|
||
display: none;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
width: 100%;
|
||
`;
|
||
} else {
|
||
// Modal mode: fixed overlay
|
||
this.container.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.75);
|
||
display: none;
|
||
z-index: 10000;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
}
|
||
|
||
// Create modal content
|
||
const modalContent = document.createElement('div');
|
||
if (this.isEmbedded) {
|
||
// Embedded content: no centering margin, full width
|
||
modalContent.style.cssText = `
|
||
position: relative;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
width: 100%;
|
||
border-radius: var(--nl-border-radius, 15px);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
overflow: hidden;
|
||
`;
|
||
} else {
|
||
// Modal content: centered with margin, no fixed height
|
||
modalContent.style.cssText = `
|
||
position: relative;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
width: 90%;
|
||
max-width: 400px;
|
||
margin: 50px auto;
|
||
border-radius: var(--nl-border-radius, 15px);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
overflow: hidden;
|
||
`;
|
||
}
|
||
|
||
// Header
|
||
const modalHeader = document.createElement('div');
|
||
modalHeader.style.cssText = `
|
||
padding: 20px 24px 0 24px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: transparent;
|
||
border-bottom: none;
|
||
`;
|
||
|
||
const modalTitle = document.createElement('h2');
|
||
modalTitle.textContent = 'Nostr Login';
|
||
modalTitle.style.cssText = `
|
||
margin: 0;
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: var(--nl-primary-color);
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
modalHeader.appendChild(modalTitle);
|
||
|
||
// Only add close button for non-embedded modals
|
||
// Embedded modals shouldn't have a close button because there's no way to reopen them
|
||
if (!this.isEmbedded) {
|
||
const closeButton = document.createElement('button');
|
||
closeButton.innerHTML = '×';
|
||
closeButton.onclick = () => this.close();
|
||
closeButton.style.cssText = `
|
||
background: var(--nl-secondary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: 4px;
|
||
font-size: 28px;
|
||
color: var(--nl-primary-color);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
closeButton.onmouseover = () => {
|
||
closeButton.style.borderColor = 'var(--nl-accent-color)';
|
||
closeButton.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
closeButton.onmouseout = () => {
|
||
closeButton.style.borderColor = 'var(--nl-primary-color)';
|
||
closeButton.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
|
||
modalHeader.appendChild(closeButton);
|
||
}
|
||
|
||
// Body
|
||
this.modalBody = document.createElement('div');
|
||
this.modalBody.style.cssText = `
|
||
padding: 24px;
|
||
background: transparent;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
modalContent.appendChild(modalHeader);
|
||
// Add version element in bottom-right corner aligned with modal body
|
||
const versionElement = document.createElement('div');
|
||
versionElement.textContent = 'v0.1.22';
|
||
versionElement.style.cssText = `
|
||
position: absolute;
|
||
bottom: 8px;
|
||
right: 24px;
|
||
font-size: 14px;
|
||
color: #666666;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
`;
|
||
modalContent.appendChild(versionElement);
|
||
|
||
modalContent.appendChild(this.modalBody);
|
||
this.container.appendChild(modalContent);
|
||
|
||
// Add to appropriate parent
|
||
if (this.isEmbedded && this.embeddedContainer) {
|
||
// Append to specified container for embedding
|
||
if (typeof this.embeddedContainer === 'string') {
|
||
const targetElement = document.querySelector(this.embeddedContainer);
|
||
if (targetElement) {
|
||
targetElement.appendChild(this.container);
|
||
} else {
|
||
console.error('NOSTR_LOGIN_LITE: Embedded container not found:', this.embeddedContainer);
|
||
document.body.appendChild(this.container);
|
||
}
|
||
} else if (this.embeddedContainer instanceof HTMLElement) {
|
||
this.embeddedContainer.appendChild(this.container);
|
||
} else {
|
||
console.error('NOSTR_LOGIN_LITE: Invalid embedded container');
|
||
document.body.appendChild(this.container);
|
||
}
|
||
} else {
|
||
// Add to body for modal mode
|
||
document.body.appendChild(this.container);
|
||
}
|
||
|
||
// Click outside to close (only for modal mode)
|
||
if (!this.isEmbedded) {
|
||
this.container.onclick = (e) => {
|
||
if (e.target === this.container) {
|
||
this.close();
|
||
}
|
||
};
|
||
}
|
||
|
||
// Update theme
|
||
this.updateTheme();
|
||
}
|
||
|
||
updateTheme() {
|
||
// The theme will automatically update through CSS custom properties
|
||
// No manual styling needed - the CSS variables handle everything
|
||
}
|
||
|
||
open(opts = {}) {
|
||
// If a NIP-46 connect is in progress, don't overwrite the connecting UI
|
||
if (this._nip46Connecting) return;
|
||
|
||
this.currentScreen = opts.startScreen;
|
||
this.isVisible = true;
|
||
this.container.style.display = 'block';
|
||
|
||
// Render login options
|
||
this._renderLoginOptions();
|
||
}
|
||
|
||
close() {
|
||
this.isVisible = false;
|
||
this.container.style.display = 'none';
|
||
this.modalBody.innerHTML = '';
|
||
}
|
||
|
||
_renderLoginOptions() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const options = [];
|
||
const methods = this.options?.methods;
|
||
const showAllMethods = !methods || methods.all === true || Object.keys(methods).length === 0;
|
||
const isMethodEnabled = (name, defaultWhenConfigured = true) => {
|
||
if (showAllMethods) return true;
|
||
const v = methods?.[name];
|
||
if (v === undefined) return defaultWhenConfigured;
|
||
return v !== false;
|
||
};
|
||
|
||
// Show explicit sign-out action when already authenticated.
|
||
try {
|
||
const current = window?.NOSTR_LOGIN_LITE?.getAuthState?.();
|
||
if (current?.method) {
|
||
const signOutButton = document.createElement('button');
|
||
signOutButton.textContent = `Sign Out (current: ${current.method})`;
|
||
signOutButton.onclick = () => {
|
||
if (window?.NOSTR_LOGIN_LITE?.logout) window.NOSTR_LOGIN_LITE.logout();
|
||
this.close();
|
||
};
|
||
signOutButton.style.cssText = this._getButtonStyle('secondary') + 'margin-bottom: 12px;';
|
||
this.modalBody.appendChild(signOutButton);
|
||
}
|
||
} catch (_) {}
|
||
|
||
// Extension option
|
||
if (isMethodEnabled('extension', true)) {
|
||
options.push({
|
||
type: 'extension',
|
||
title: 'Browser Extension',
|
||
description: 'Use your browser extension',
|
||
icon: '🔌'
|
||
});
|
||
}
|
||
|
||
// Local key option
|
||
if (isMethodEnabled('local', true)) {
|
||
options.push({
|
||
type: 'local',
|
||
title: 'Local Key',
|
||
description: 'Create or import your own key',
|
||
icon: '🔑'
|
||
});
|
||
}
|
||
|
||
// Seed phrase option
|
||
if (isMethodEnabled('seedphrase', false)) {
|
||
options.push({
|
||
type: 'seedphrase',
|
||
title: 'Seed Phrase',
|
||
description: 'Import from mnemonic seed phrase',
|
||
icon: '🌱'
|
||
});
|
||
}
|
||
|
||
// Nostr Connect option (supports legacy `remote` toggle)
|
||
if (showAllMethods || (isMethodEnabled('connect', false) && isMethodEnabled('remote', true))) {
|
||
options.push({
|
||
type: 'connect',
|
||
title: 'Nostr Connect',
|
||
description: 'Connect with external signer',
|
||
icon: '🌐'
|
||
});
|
||
}
|
||
|
||
// Unified hardware signer option (Web Serial primary, WebUSB fallback)
|
||
const nsignerEnabled = isMethodEnabled('nsigner', true) || isMethodEnabled('nsigner_cyd', true);
|
||
if (nsignerEnabled) {
|
||
const secure = typeof window !== 'undefined' ? window.isSecureContext : false;
|
||
const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator);
|
||
const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator);
|
||
|
||
let description = 'Use your hardware signer (auto-detect serial/USB)';
|
||
let disabled = false;
|
||
|
||
if (!secure) {
|
||
description = 'Requires HTTPS or localhost';
|
||
disabled = true;
|
||
} else if (!hasSerial && !hasUsb) {
|
||
description = 'Requires Web Serial or WebUSB support (Chrome/Edge/Brave)';
|
||
disabled = true;
|
||
}
|
||
|
||
options.push({
|
||
type: 'nsigner',
|
||
title: 'USB Hardware Signer',
|
||
description,
|
||
icon: '🔐',
|
||
disabled
|
||
});
|
||
}
|
||
|
||
// Read-only option
|
||
if (isMethodEnabled('readonly', true)) {
|
||
options.push({
|
||
type: 'readonly',
|
||
title: 'Read Only',
|
||
description: 'Browse without signing',
|
||
icon: '👁️'
|
||
});
|
||
}
|
||
|
||
// OTP/DM option
|
||
if (isMethodEnabled('otp', false)) {
|
||
options.push({
|
||
type: 'otp',
|
||
title: 'DM/OTP',
|
||
description: 'Receive OTP via DM',
|
||
icon: '📱'
|
||
});
|
||
}
|
||
|
||
// Render each option
|
||
options.forEach(option => {
|
||
const button = document.createElement('button');
|
||
button.disabled = !!option.disabled;
|
||
button.onclick = () => {
|
||
if (!option.disabled) this._handleOptionClick(option.type);
|
||
};
|
||
button.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
background: var(--nl-secondary-color);
|
||
color: ${option.disabled ? 'var(--nl-muted-color)' : 'var(--nl-primary-color)'};
|
||
border: var(--nl-border-width) solid ${option.disabled ? 'var(--nl-muted-color)' : 'var(--nl-primary-color)'};
|
||
border-radius: var(--nl-border-radius);
|
||
cursor: ${option.disabled ? 'not-allowed' : 'pointer'};
|
||
transition: all 0.2s;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
opacity: ${option.disabled ? '0.7' : '1'};
|
||
`;
|
||
button.onmouseover = () => {
|
||
if (option.disabled) return;
|
||
button.style.borderColor = 'var(--nl-accent-color)';
|
||
button.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
button.onmouseout = () => {
|
||
button.style.borderColor = option.disabled ? 'var(--nl-muted-color)' : 'var(--nl-primary-color)';
|
||
button.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
|
||
const iconDiv = document.createElement('div');
|
||
// Remove the icon entirely - no emojis or text-based icons
|
||
iconDiv.textContent = '';
|
||
iconDiv.style.cssText = `
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
margin-right: 16px;
|
||
width: 0px;
|
||
text-align: center;
|
||
color: var(--nl-primary-color);
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
const contentDiv = document.createElement('div');
|
||
contentDiv.style.cssText = 'flex: 1; text-align: left;';
|
||
|
||
const titleDiv = document.createElement('div');
|
||
titleDiv.textContent = option.title;
|
||
titleDiv.style.cssText = `
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
color: var(--nl-primary-color);
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
const descDiv = document.createElement('div');
|
||
descDiv.textContent = option.description;
|
||
descDiv.style.cssText = `
|
||
font-size: 14px;
|
||
color: #666666;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
contentDiv.appendChild(titleDiv);
|
||
contentDiv.appendChild(descDiv);
|
||
|
||
button.appendChild(iconDiv);
|
||
button.appendChild(contentDiv);
|
||
this.modalBody.appendChild(button);
|
||
});
|
||
}
|
||
|
||
_handleOptionClick(type) {
|
||
// console.log('Selected login type:', type);
|
||
|
||
// Handle different login types
|
||
switch (type) {
|
||
case 'extension':
|
||
this._handleExtension();
|
||
break;
|
||
case 'local':
|
||
this._showLocalKeyScreen();
|
||
break;
|
||
case 'seedphrase':
|
||
this._showSeedPhraseScreen();
|
||
break;
|
||
case 'connect':
|
||
this._showConnectScreen();
|
||
break;
|
||
case 'nsigner':
|
||
case 'nsigner_cyd':
|
||
this._showHardwareSignerScreen({ autoPrompt: true });
|
||
break;
|
||
case 'readonly':
|
||
this._handleReadonly();
|
||
break;
|
||
case 'otp':
|
||
this._showOtpScreen();
|
||
break;
|
||
}
|
||
}
|
||
|
||
_handleExtension() {
|
||
// SIMPLIFIED ARCHITECTURE: Check for single extension at window.nostr or preserved extension
|
||
let extension = null;
|
||
|
||
// Check if NostrLite instance has a preserved extension (real extension detected at init)
|
||
if (window.NOSTR_LOGIN_LITE?._instance?.preservedExtension) {
|
||
extension = window.NOSTR_LOGIN_LITE._instance.preservedExtension;
|
||
// console.log('Modal: Using preserved extension:', extension.constructor?.name);
|
||
}
|
||
// Otherwise check current window.nostr
|
||
else if (window.nostr && this._isRealExtension(window.nostr)) {
|
||
extension = window.nostr;
|
||
// console.log('Modal: Using current window.nostr extension:', extension.constructor?.name);
|
||
}
|
||
|
||
if (!extension) {
|
||
// console.log('Modal: No extension detected yet, waiting for deferred detection...');
|
||
|
||
// DEFERRED EXTENSION CHECK: Extensions like nos2x might load after our library
|
||
let attempts = 0;
|
||
const maxAttempts = 10; // Try for 2 seconds
|
||
const checkForExtension = () => {
|
||
attempts++;
|
||
|
||
// Check again for preserved extension (might be set by deferred detection)
|
||
if (window.NOSTR_LOGIN_LITE?._instance?.preservedExtension) {
|
||
extension = window.NOSTR_LOGIN_LITE._instance.preservedExtension;
|
||
// console.log('Modal: Found preserved extension after waiting:', extension.constructor?.name);
|
||
this._tryExtensionLogin(extension);
|
||
return;
|
||
}
|
||
|
||
// Check current window.nostr again
|
||
if (window.nostr && this._isRealExtension(window.nostr)) {
|
||
extension = window.nostr;
|
||
// console.log('Modal: Found extension at window.nostr after waiting:', extension.constructor?.name);
|
||
this._tryExtensionLogin(extension);
|
||
return;
|
||
}
|
||
|
||
// Keep trying or give up
|
||
if (attempts < maxAttempts) {
|
||
setTimeout(checkForExtension, 200);
|
||
} else {
|
||
// console.log('Modal: No browser extension found after waiting 2 seconds');
|
||
this._showExtensionRequired();
|
||
}
|
||
};
|
||
|
||
// Start checking after a brief delay
|
||
setTimeout(checkForExtension, 200);
|
||
return;
|
||
}
|
||
|
||
// Use the single detected extension directly - no choice UI
|
||
// console.log('Modal: Single extension mode - using extension directly');
|
||
this._tryExtensionLogin(extension);
|
||
}
|
||
|
||
_detectAllExtensions() {
|
||
const extensions = [];
|
||
const seenExtensions = new Set(); // Track extensions by object reference to avoid duplicates
|
||
|
||
// Extension locations to check (in priority order)
|
||
const locations = [
|
||
{ path: 'window.navigator?.nostr', name: 'navigator.nostr', displayName: 'Standard Extension (navigator.nostr)', icon: '🌐', getter: () => window.navigator?.nostr },
|
||
{ path: 'window.webln?.nostr', name: 'webln.nostr', displayName: 'Alby WebLN Extension', icon: '⚡', getter: () => window.webln?.nostr },
|
||
{ path: 'window.alby?.nostr', name: 'alby.nostr', displayName: 'Alby Extension (Direct)', icon: '🐝', getter: () => window.alby?.nostr },
|
||
{ path: 'window.nos2x', name: 'nos2x', displayName: 'nos2x Extension', icon: '🔌', getter: () => window.nos2x },
|
||
{ path: 'window.flamingo?.nostr', name: 'flamingo.nostr', displayName: 'Flamingo Extension', icon: '🦩', getter: () => window.flamingo?.nostr },
|
||
{ path: 'window.mutiny?.nostr', name: 'mutiny.nostr', displayName: 'Mutiny Extension', icon: '⚔️', getter: () => window.mutiny?.nostr },
|
||
{ path: 'window.nostrich?.nostr', name: 'nostrich.nostr', displayName: 'Nostrich Extension', icon: '🐦', getter: () => window.nostrich?.nostr },
|
||
{ path: 'window.getAlby?.nostr', name: 'getAlby.nostr', displayName: 'getAlby Extension', icon: '🔧', getter: () => window.getAlby?.nostr }
|
||
];
|
||
|
||
// Check each location
|
||
for (const location of locations) {
|
||
try {
|
||
const obj = location.getter();
|
||
|
||
// console.log(`Modal: Checking ${location.name}:`, !!obj, obj?.constructor?.name);
|
||
|
||
if (obj && this._isRealExtension(obj) && !seenExtensions.has(obj)) {
|
||
extensions.push({
|
||
name: location.name,
|
||
displayName: location.displayName,
|
||
icon: location.icon,
|
||
extension: obj
|
||
});
|
||
seenExtensions.add(obj);
|
||
// console.log(`Modal: ✓ Detected extension at ${location.name} (${obj.constructor?.name})`);
|
||
} else if (obj) {
|
||
// console.log(`Modal: ✗ Filtered out ${location.name} (${obj.constructor?.name})`);
|
||
}
|
||
} catch (e) {
|
||
// Location doesn't exist or can't be accessed
|
||
// console.log(`Modal: ${location.name} not accessible:`, e.message);
|
||
}
|
||
}
|
||
|
||
// Also check window.nostr but be extra careful to avoid our library
|
||
// console.log('Modal: Checking window.nostr:', !!window.nostr, window.nostr?.constructor?.name);
|
||
|
||
if (window.nostr) {
|
||
// Check if window.nostr is our WindowNostr facade with a preserved extension
|
||
if (window.nostr.constructor?.name === 'WindowNostr' && window.nostr.existingNostr) {
|
||
// console.log('Modal: Found WindowNostr facade, checking existingNostr for preserved extension');
|
||
const preservedExtension = window.nostr.existingNostr;
|
||
// console.log('Modal: Preserved extension:', !!preservedExtension, preservedExtension?.constructor?.name);
|
||
|
||
if (preservedExtension && this._isRealExtension(preservedExtension) && !seenExtensions.has(preservedExtension)) {
|
||
extensions.push({
|
||
name: 'window.nostr.existingNostr',
|
||
displayName: 'Extension (preserved by WindowNostr)',
|
||
icon: '🔑',
|
||
extension: preservedExtension
|
||
});
|
||
seenExtensions.add(preservedExtension);
|
||
// console.log(`Modal: ✓ Detected preserved extension: ${preservedExtension.constructor?.name}`);
|
||
}
|
||
}
|
||
// Check if window.nostr is directly a real extension (not our facade)
|
||
else if (this._isRealExtension(window.nostr) && !seenExtensions.has(window.nostr)) {
|
||
extensions.push({
|
||
name: 'window.nostr',
|
||
displayName: 'Extension (window.nostr)',
|
||
icon: '🔑',
|
||
extension: window.nostr
|
||
});
|
||
seenExtensions.add(window.nostr);
|
||
// console.log(`Modal: ✓ Detected extension at window.nostr: ${window.nostr.constructor?.name}`);
|
||
} else {
|
||
// console.log(`Modal: ✗ Filtered out window.nostr (${window.nostr.constructor?.name}) - not a real extension`);
|
||
}
|
||
}
|
||
|
||
return extensions;
|
||
}
|
||
|
||
_isRealExtension(obj) {
|
||
// console.log(`Modal: EXTENSIVE DEBUG - _isRealExtension called with:`, obj);
|
||
// console.log(`Modal: Object type: ${typeof obj}`);
|
||
// console.log(`Modal: Object truthy: ${!!obj}`);
|
||
|
||
if (!obj || typeof obj !== 'object') {
|
||
// console.log(`Modal: REJECT - Not an object`);
|
||
return false;
|
||
}
|
||
|
||
// console.log(`Modal: getPublicKey type: ${typeof obj.getPublicKey}`);
|
||
// console.log(`Modal: signEvent type: ${typeof obj.signEvent}`);
|
||
|
||
// Must have required Nostr methods
|
||
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') {
|
||
// console.log(`Modal: REJECT - Missing required methods`);
|
||
return false;
|
||
}
|
||
|
||
// Exclude NostrTools library object
|
||
if (obj === window.NostrTools) {
|
||
// console.log(`Modal: REJECT - Is NostrTools object`);
|
||
return false;
|
||
}
|
||
|
||
// Use the EXACT SAME logic as the comprehensive test (lines 804-809)
|
||
// This is the key fix - match the comprehensive test's successful detection logic
|
||
const constructorName = obj.constructor?.name;
|
||
const objectKeys = Object.keys(obj);
|
||
|
||
// console.log(`Modal: Constructor name: "${constructorName}"`);
|
||
// console.log(`Modal: Object keys: [${objectKeys.join(', ')}]`);
|
||
|
||
// COMPREHENSIVE TEST LOGIC - Accept anything with required methods that's not our specific library classes
|
||
const isRealExtension = (
|
||
typeof obj.getPublicKey === 'function' &&
|
||
typeof obj.signEvent === 'function' &&
|
||
constructorName !== 'WindowNostr' && // Our library class
|
||
constructorName !== 'NostrLite' // Our main class
|
||
);
|
||
|
||
// console.log(`Modal: Using comprehensive test logic:`);
|
||
// console.log(` Has getPublicKey: ${typeof obj.getPublicKey === 'function'}`);
|
||
// console.log(` Has signEvent: ${typeof obj.signEvent === 'function'}`);
|
||
// console.log(` Not WindowNostr: ${constructorName !== 'WindowNostr'}`);
|
||
// console.log(` Not NostrLite: ${constructorName !== 'NostrLite'}`);
|
||
// console.log(` Constructor: "${constructorName}"`);
|
||
|
||
// Additional debugging for comparison
|
||
const extensionPropChecks = {
|
||
_isEnabled: !!obj._isEnabled,
|
||
enabled: !!obj.enabled,
|
||
kind: !!obj.kind,
|
||
_eventEmitter: !!obj._eventEmitter,
|
||
_scope: !!obj._scope,
|
||
_requests: !!obj._requests,
|
||
_pubkey: !!obj._pubkey,
|
||
name: !!obj.name,
|
||
version: !!obj.version,
|
||
description: !!obj.description
|
||
};
|
||
|
||
// console.log(`Modal: Extension property analysis:`, extensionPropChecks);
|
||
|
||
const hasExtensionProps = !!(
|
||
obj._isEnabled || obj.enabled || obj.kind ||
|
||
obj._eventEmitter || obj._scope || obj._requests || obj._pubkey ||
|
||
obj.name || obj.version || obj.description
|
||
);
|
||
|
||
const underscoreKeys = objectKeys.filter(key => key.startsWith('_'));
|
||
const hexToUint8Keys = objectKeys.filter(key => key.startsWith('_hex'));
|
||
// console.log(`Modal: Underscore keys: [${underscoreKeys.join(', ')}]`);
|
||
// console.log(`Modal: _hex* keys: [${hexToUint8Keys.join(', ')}]`);
|
||
|
||
// console.log(`Modal: Additional analysis:`);
|
||
// console.log(` hasExtensionProps: ${hasExtensionProps}`);
|
||
// console.log(` hasLibraryMethod (_hexToUint8Array): ${objectKeys.includes('_hexToUint8Array')}`);
|
||
|
||
// console.log(`Modal: COMPREHENSIVE TEST LOGIC RESULT: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`);
|
||
// console.log(`Modal: FINAL DECISION for ${constructorName}: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`);
|
||
|
||
return isRealExtension;
|
||
}
|
||
|
||
_showExtensionChoice(extensions) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Choose Browser Extension';
|
||
title.style.cssText = `
|
||
margin: 0 0 16px 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--nl-primary-color);
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = `Found ${extensions.length} Nostr extensions. Choose which one to use:`;
|
||
description.style.cssText = `
|
||
margin-bottom: 20px;
|
||
color: #666666;
|
||
font-size: 14px;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
|
||
// Create button for each extension
|
||
extensions.forEach((ext, index) => {
|
||
const button = document.createElement('button');
|
||
button.onclick = () => this._tryExtensionLogin(ext.extension);
|
||
button.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
text-align: left;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
button.onmouseover = () => {
|
||
button.style.borderColor = 'var(--nl-accent-color)';
|
||
button.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
button.onmouseout = () => {
|
||
button.style.borderColor = 'var(--nl-primary-color)';
|
||
button.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
|
||
const iconDiv = document.createElement('div');
|
||
iconDiv.textContent = ext.icon;
|
||
iconDiv.style.cssText = `
|
||
font-size: 24px;
|
||
margin-right: 16px;
|
||
width: 24px;
|
||
text-align: center;
|
||
`;
|
||
|
||
const contentDiv = document.createElement('div');
|
||
contentDiv.style.cssText = 'flex: 1;';
|
||
|
||
const nameDiv = document.createElement('div');
|
||
nameDiv.textContent = ext.displayName;
|
||
nameDiv.style.cssText = `
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
color: var(--nl-primary-color);
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
const pathDiv = document.createElement('div');
|
||
pathDiv.textContent = ext.name;
|
||
pathDiv.style.cssText = `
|
||
font-size: 12px;
|
||
color: #666666;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
contentDiv.appendChild(nameDiv);
|
||
contentDiv.appendChild(pathDiv);
|
||
|
||
button.appendChild(iconDiv);
|
||
button.appendChild(contentDiv);
|
||
this.modalBody.appendChild(button);
|
||
});
|
||
|
||
// Add back button
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back to Login Options';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 20px;';
|
||
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
async _tryExtensionLogin(extensionObj) {
|
||
try {
|
||
// Show loading state
|
||
this.modalBody.innerHTML = '<div style="text-align: center; padding: 20px;">🔄 Connecting to extension...</div>';
|
||
|
||
// Get pubkey from extension
|
||
const pubkey = await extensionObj.getPublicKey();
|
||
console.log('Extension provided pubkey:', pubkey);
|
||
|
||
// Set extension method with the extension object
|
||
this._setAuthMethod('extension', { pubkey, extension: extensionObj });
|
||
|
||
} catch (error) {
|
||
console.error('Extension login failed:', error);
|
||
this._showError(`Extension login failed: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
_showLocalKeyScreen() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const description = document.createElement('p');
|
||
description.innerHTML = 'Enter your secret key in nsec or hex format, or <span id="generate-new" style="text-decoration: underline; cursor: pointer; color: var(--nl-primary-color);">generate new</span>.';
|
||
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
|
||
|
||
const textarea = document.createElement('textarea');
|
||
textarea.placeholder = 'Enter your secret key:\n• nsec1... (bech32 format)\n• 64-character hex string';
|
||
textarea.style.cssText = `
|
||
width: 100%;
|
||
height: 100px;
|
||
padding: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
resize: none;
|
||
font-family: monospace;
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
// Add real-time format detection
|
||
const formatHint = document.createElement('div');
|
||
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
|
||
|
||
const importButton = document.createElement('button');
|
||
importButton.textContent = 'Import Key';
|
||
importButton.disabled = true;
|
||
importButton.onclick = () => {
|
||
if (!importButton.disabled) {
|
||
this._importLocalKey(textarea.value);
|
||
}
|
||
};
|
||
|
||
// Set initial disabled state
|
||
importButton.style.cssText = `
|
||
display: block;
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: var(--nl-border-width) solid var(--nl-muted-color);
|
||
border-radius: var(--nl-border-radius);
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
cursor: not-allowed;
|
||
transition: all 0.2s;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-muted-color);
|
||
`;
|
||
|
||
textarea.oninput = () => {
|
||
const value = textarea.value.trim();
|
||
if (!value) {
|
||
formatHint.textContent = '';
|
||
// Disable button
|
||
importButton.disabled = true;
|
||
importButton.style.borderColor = 'var(--nl-muted-color)';
|
||
importButton.style.color = 'var(--nl-muted-color)';
|
||
importButton.style.cursor = 'not-allowed';
|
||
return;
|
||
}
|
||
|
||
const format = this._detectKeyFormat(value);
|
||
if (format === 'nsec') {
|
||
formatHint.textContent = '✅ Valid nsec format detected';
|
||
formatHint.style.color = '#059669';
|
||
// Enable button
|
||
importButton.disabled = false;
|
||
importButton.style.borderColor = 'var(--nl-primary-color)';
|
||
importButton.style.color = 'var(--nl-primary-color)';
|
||
importButton.style.cursor = 'pointer';
|
||
} else if (format === 'hex') {
|
||
formatHint.textContent = '✅ Valid hex format detected';
|
||
formatHint.style.color = '#059669';
|
||
// Enable button
|
||
importButton.disabled = false;
|
||
importButton.style.borderColor = 'var(--nl-primary-color)';
|
||
importButton.style.color = 'var(--nl-primary-color)';
|
||
importButton.style.cursor = 'pointer';
|
||
} else {
|
||
formatHint.textContent = '❌ Invalid key format - must be nsec1... or 64-character hex';
|
||
formatHint.style.color = '#dc2626';
|
||
// Disable button
|
||
importButton.disabled = true;
|
||
importButton.style.borderColor = 'var(--nl-muted-color)';
|
||
importButton.style.color = 'var(--nl-muted-color)';
|
||
importButton.style.cursor = 'not-allowed';
|
||
}
|
||
};
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(textarea);
|
||
this.modalBody.appendChild(formatHint);
|
||
this.modalBody.appendChild(importButton);
|
||
this.modalBody.appendChild(backButton);
|
||
|
||
// Add click handler for the "generate new" link
|
||
const generateLink = document.getElementById('generate-new');
|
||
if (generateLink) {
|
||
generateLink.addEventListener('mouseenter', () => {
|
||
generateLink.style.color = 'var(--nl-accent-color)';
|
||
});
|
||
generateLink.addEventListener('mouseleave', () => {
|
||
generateLink.style.color = 'var(--nl-primary-color)';
|
||
});
|
||
generateLink.addEventListener('click', () => {
|
||
this._generateNewLocalKey(textarea, formatHint);
|
||
});
|
||
}
|
||
}
|
||
|
||
_generateNewLocalKey(textarea, formatHint) {
|
||
try {
|
||
// Generate a new secret key using NostrTools
|
||
const sk = window.NostrTools.generateSecretKey();
|
||
const nsec = window.NostrTools.nip19.nsecEncode(sk);
|
||
|
||
// Set the generated key in the textarea
|
||
textarea.value = nsec;
|
||
|
||
// Trigger the oninput event to properly validate and enable the button
|
||
if (textarea.oninput) {
|
||
textarea.oninput();
|
||
}
|
||
|
||
// console.log('Generated new local secret key (nsec format)');
|
||
|
||
} catch (error) {
|
||
console.error('Failed to generate local key:', error);
|
||
formatHint.textContent = '❌ Failed to generate key - NostrTools not available';
|
||
formatHint.style.color = '#dc2626';
|
||
}
|
||
}
|
||
|
||
_createLocalKey() {
|
||
try {
|
||
const sk = window.NostrTools.generateSecretKey();
|
||
const pk = window.NostrTools.getPublicKey(sk);
|
||
const nsec = window.NostrTools.nip19.nsecEncode(sk);
|
||
const npub = window.NostrTools.nip19.npubEncode(pk);
|
||
|
||
this._showKeyDisplay(pk, nsec, 'created');
|
||
} catch (error) {
|
||
this._showError('Failed to create key: ' + error.message);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
_detectKeyFormat(keyValue) {
|
||
const trimmed = keyValue.trim();
|
||
|
||
// Check for nsec format
|
||
if (trimmed.startsWith('nsec1') && trimmed.length === 63) {
|
||
try {
|
||
window.NostrTools.nip19.decode(trimmed);
|
||
return 'nsec';
|
||
} catch {
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// Check for hex format (64 characters, valid hex)
|
||
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
|
||
return 'hex';
|
||
}
|
||
|
||
return 'invalid';
|
||
}
|
||
|
||
_importLocalKey(keyValue) {
|
||
try {
|
||
const trimmed = keyValue.trim();
|
||
if (!trimmed) {
|
||
throw new Error('Please enter a secret key');
|
||
}
|
||
|
||
const format = this._detectKeyFormat(trimmed);
|
||
let sk;
|
||
|
||
if (format === 'nsec') {
|
||
// Decode nsec format - this returns Uint8Array
|
||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||
if (decoded.type !== 'nsec') {
|
||
throw new Error('Invalid nsec format');
|
||
}
|
||
sk = decoded.data; // This is already Uint8Array
|
||
} else if (format === 'hex') {
|
||
// Convert hex string to Uint8Array
|
||
sk = this._hexToUint8Array(trimmed);
|
||
// Test that it's a valid secret key by trying to get public key
|
||
window.NostrTools.getPublicKey(sk);
|
||
} else {
|
||
throw new Error('Invalid key format. Please enter either nsec1... or 64-character hex string');
|
||
}
|
||
|
||
// Generate public key and encoded formats
|
||
const pk = window.NostrTools.getPublicKey(sk);
|
||
const nsec = window.NostrTools.nip19.nsecEncode(sk);
|
||
const npub = window.NostrTools.nip19.npubEncode(pk);
|
||
|
||
this._showKeyDisplay(pk, nsec, 'imported');
|
||
} catch (error) {
|
||
this._showError('Invalid key: ' + error.message);
|
||
}
|
||
}
|
||
|
||
_hexToUint8Array(hex) {
|
||
// Convert hex string to Uint8Array
|
||
if (hex.length % 2 !== 0) {
|
||
throw new Error('Invalid hex string length');
|
||
}
|
||
const bytes = new Uint8Array(hex.length / 2);
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
_showKeyDisplay(pubkey, nsec, action) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = `Key ${action} successfully!`;
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
|
||
|
||
const warningDiv = document.createElement('div');
|
||
warningDiv.textContent = '⚠️ Save your secret key securely!';
|
||
warningDiv.style.cssText = 'background: #fef3c7; color: #92400e; padding: 12px; border-radius: 6px; margin-bottom: 16px; font-size: 12px;';
|
||
|
||
// Helper function to create copy button
|
||
const createCopyButton = (text, label) => {
|
||
const copyBtn = document.createElement('button');
|
||
copyBtn.textContent = `Copy ${label}`;
|
||
copyBtn.style.cssText = `
|
||
margin-left: 8px;
|
||
padding: 4px 8px;
|
||
font-size: 10px;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: 1px solid var(--nl-primary-color);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
copyBtn.onclick = async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
const originalText = copyBtn.textContent;
|
||
copyBtn.textContent = '✓ Copied!';
|
||
copyBtn.style.color = '#059669';
|
||
setTimeout(() => {
|
||
copyBtn.textContent = originalText;
|
||
copyBtn.style.color = 'var(--nl-primary-color)';
|
||
}, 2000);
|
||
} catch (err) {
|
||
console.error('Failed to copy:', err);
|
||
copyBtn.textContent = '✗ Failed';
|
||
copyBtn.style.color = '#dc2626';
|
||
setTimeout(() => {
|
||
copyBtn.textContent = originalText;
|
||
copyBtn.style.color = 'var(--nl-primary-color)';
|
||
}, 2000);
|
||
}
|
||
};
|
||
return copyBtn;
|
||
};
|
||
|
||
// Convert pubkey to hex for verification
|
||
const pubkeyHex = typeof pubkey === 'string' ? pubkey : Array.from(pubkey).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
|
||
// Decode nsec to get secret key as hex
|
||
let secretKeyHex = '';
|
||
try {
|
||
const decoded = window.NostrTools.nip19.decode(nsec);
|
||
secretKeyHex = Array.from(decoded.data).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
} catch (err) {
|
||
console.error('Failed to decode nsec for hex display:', err);
|
||
}
|
||
|
||
// Secret Key Section
|
||
const nsecSection = document.createElement('div');
|
||
nsecSection.style.cssText = 'margin-bottom: 16px;';
|
||
|
||
const nsecLabel = document.createElement('div');
|
||
nsecLabel.innerHTML = '<strong>Your Secret Key (nsec):</strong>';
|
||
nsecLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
|
||
|
||
const nsecContainer = document.createElement('div');
|
||
nsecContainer.style.cssText = 'margin-bottom: 8px;';
|
||
|
||
const nsecCode = document.createElement('code');
|
||
nsecCode.textContent = nsec;
|
||
nsecCode.style.cssText = `
|
||
display: block;
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
background: #f3f4f6;
|
||
padding: 6px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
line-height: 1.3;
|
||
font-family: 'Courier New', monospace;
|
||
margin-bottom: 4px;
|
||
`;
|
||
|
||
const nsecCopyBtn = createCopyButton(nsec, 'nsec');
|
||
nsecCopyBtn.style.cssText += 'display: inline-block; margin-left: 0;';
|
||
|
||
nsecContainer.appendChild(nsecCode);
|
||
nsecContainer.appendChild(nsecCopyBtn);
|
||
nsecSection.appendChild(nsecLabel);
|
||
nsecSection.appendChild(nsecContainer);
|
||
|
||
// Secret Key Hex Section
|
||
if (secretKeyHex) {
|
||
const secretHexLabel = document.createElement('div');
|
||
secretHexLabel.innerHTML = '<strong>Secret Key (hex):</strong>';
|
||
secretHexLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
|
||
|
||
const secretHexContainer = document.createElement('div');
|
||
secretHexContainer.style.cssText = 'margin-bottom: 8px;';
|
||
|
||
const secretHexCode = document.createElement('code');
|
||
secretHexCode.textContent = secretKeyHex;
|
||
secretHexCode.style.cssText = `
|
||
display: block;
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
background: #f3f4f6;
|
||
padding: 6px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
line-height: 1.3;
|
||
font-family: 'Courier New', monospace;
|
||
margin-bottom: 4px;
|
||
`;
|
||
|
||
const secretHexCopyBtn = createCopyButton(secretKeyHex, 'hex');
|
||
secretHexCopyBtn.style.cssText += 'display: inline-block; margin-left: 0;';
|
||
|
||
secretHexContainer.appendChild(secretHexCode);
|
||
secretHexContainer.appendChild(secretHexCopyBtn);
|
||
nsecSection.appendChild(secretHexLabel);
|
||
nsecSection.appendChild(secretHexContainer);
|
||
}
|
||
|
||
// Public Key Section
|
||
const npubSection = document.createElement('div');
|
||
npubSection.style.cssText = 'margin-bottom: 16px;';
|
||
|
||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||
|
||
const npubLabel = document.createElement('div');
|
||
npubLabel.innerHTML = '<strong>Your Public Key (npub):</strong>';
|
||
npubLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
|
||
|
||
const npubContainer = document.createElement('div');
|
||
npubContainer.style.cssText = 'margin-bottom: 8px;';
|
||
|
||
const npubCode = document.createElement('code');
|
||
npubCode.textContent = npub;
|
||
npubCode.style.cssText = `
|
||
display: block;
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
background: #f3f4f6;
|
||
padding: 6px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
line-height: 1.3;
|
||
font-family: 'Courier New', monospace;
|
||
margin-bottom: 4px;
|
||
`;
|
||
|
||
const npubCopyBtn = createCopyButton(npub, 'npub');
|
||
npubCopyBtn.style.cssText += 'display: inline-block; margin-left: 0;';
|
||
|
||
npubContainer.appendChild(npubCode);
|
||
npubContainer.appendChild(npubCopyBtn);
|
||
npubSection.appendChild(npubLabel);
|
||
npubSection.appendChild(npubContainer);
|
||
|
||
// Public Key Hex Section
|
||
const pubHexLabel = document.createElement('div');
|
||
pubHexLabel.innerHTML = '<strong>Public Key (hex):</strong>';
|
||
pubHexLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
|
||
|
||
const pubHexContainer = document.createElement('div');
|
||
pubHexContainer.style.cssText = '';
|
||
|
||
const pubHexCode = document.createElement('code');
|
||
pubHexCode.textContent = pubkeyHex;
|
||
pubHexCode.style.cssText = `
|
||
display: block;
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
background: #f3f4f6;
|
||
padding: 6px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
line-height: 1.3;
|
||
font-family: 'Courier New', monospace;
|
||
margin-bottom: 4px;
|
||
`;
|
||
|
||
const pubHexCopyBtn = createCopyButton(pubkeyHex, 'hex');
|
||
pubHexCopyBtn.style.cssText += 'display: inline-block; margin-left: 0;';
|
||
|
||
pubHexContainer.appendChild(pubHexCode);
|
||
pubHexContainer.appendChild(pubHexCopyBtn);
|
||
npubSection.appendChild(pubHexLabel);
|
||
npubSection.appendChild(pubHexContainer);
|
||
|
||
const continueButton = document.createElement('button');
|
||
continueButton.textContent = 'Continue';
|
||
continueButton.onclick = () => this._setAuthMethod('local', { secret: nsec, pubkey });
|
||
continueButton.style.cssText = this._getButtonStyle();
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(warningDiv);
|
||
this.modalBody.appendChild(nsecSection);
|
||
this.modalBody.appendChild(npubSection);
|
||
this.modalBody.appendChild(continueButton);
|
||
}
|
||
|
||
_setAuthMethod(method, options = {}) {
|
||
// console.log('Modal: _setAuthMethod called with:', method, options);
|
||
|
||
// CRITICAL: Never install facade for extension methods - leave window.nostr as the extension
|
||
if (method === 'extension') {
|
||
// console.log('Modal: Extension method - NOT installing facade, leaving window.nostr as extension');
|
||
|
||
// Save extension authentication state using global setAuthState function
|
||
if (typeof window.setAuthState === 'function') {
|
||
// console.log('Modal: Saving extension auth state to storage');
|
||
window.setAuthState({ method, ...options }, { isolateSession: this.options?.isolateSession });
|
||
}
|
||
|
||
// Emit auth method selection directly for extension
|
||
const event = new CustomEvent('nlMethodSelected', {
|
||
detail: { method, ...options }
|
||
});
|
||
window.dispatchEvent(event);
|
||
|
||
this.close();
|
||
return;
|
||
}
|
||
|
||
// FOR NON-EXTENSION METHODS: Force-install facade with resilience
|
||
// console.log('Modal: Non-extension method - FORCE-INSTALLING facade with resilience:', method);
|
||
|
||
// Store the current extension if any (for potential restoration later)
|
||
const currentExtension = (window.nostr?.constructor?.name !== 'WindowNostr') ? window.nostr : null;
|
||
|
||
// Get NostrLite instance for facade operations
|
||
const nostrLiteInstance = window.NOSTR_LOGIN_LITE?._instance;
|
||
if (!nostrLiteInstance || typeof nostrLiteInstance._installFacade !== 'function') {
|
||
console.error('Modal: Cannot access NostrLite instance or _installFacade method');
|
||
// Fallback: emit event anyway
|
||
const event = new CustomEvent('nlMethodSelected', {
|
||
detail: { method, ...options }
|
||
});
|
||
window.dispatchEvent(event);
|
||
this.close();
|
||
return;
|
||
}
|
||
|
||
// IMMEDIATE FACADE INSTALLATION
|
||
// console.log('Modal: Installing WindowNostr facade immediately for method:', method);
|
||
const preservedExtension = nostrLiteInstance.preservedExtension || currentExtension;
|
||
nostrLiteInstance._installFacade(preservedExtension, true);
|
||
// console.log('Modal: WindowNostr facade force-installed, current window.nostr:', window.nostr?.constructor?.name);
|
||
|
||
// DELAYED FACADE RESILIENCE - Reinstall after extension override attempts
|
||
const forceReinstallFacade = () => {
|
||
console.log('Modal: RESILIENCE CHECK - Current window.nostr after delay:', window.nostr?.constructor?.name);
|
||
|
||
// If facade was overridden by extension, reinstall it
|
||
if (window.nostr?.constructor?.name !== 'WindowNostr') {
|
||
console.log('Modal: FACADE OVERRIDDEN! Force-reinstalling WindowNostr facade for user choice:', method);
|
||
nostrLiteInstance._installFacade(preservedExtension, true);
|
||
console.log('Modal: Resilient facade force-reinstall complete, window.nostr:', window.nostr?.constructor?.name);
|
||
|
||
// Schedule another check in case of persistent extension override
|
||
setTimeout(() => {
|
||
if (window.nostr?.constructor?.name !== 'WindowNostr') {
|
||
console.log('Modal: PERSISTENT OVERRIDE! Final facade force-reinstall for method:', method);
|
||
nostrLiteInstance._installFacade(preservedExtension, true);
|
||
}
|
||
}, 1000);
|
||
} else {
|
||
console.log('Modal: Facade persistence verified - no override detected');
|
||
}
|
||
};
|
||
|
||
// Schedule resilience checks at multiple intervals
|
||
// setTimeout(forceReinstallFacade, 100); // Quick check
|
||
// setTimeout(forceReinstallFacade, 500); // Main check
|
||
// setTimeout(forceReinstallFacade, 1500); // Final check
|
||
|
||
// Emit auth method selection
|
||
const event = new CustomEvent('nlMethodSelected', {
|
||
detail: { method, ...options }
|
||
});
|
||
window.dispatchEvent(event);
|
||
|
||
this.close();
|
||
}
|
||
|
||
_showError(message) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const errorDiv = document.createElement('div');
|
||
errorDiv.style.cssText = 'background: #fee2e2; color: #dc2626; padding: 16px; border-radius: 6px; margin-bottom: 16px;';
|
||
errorDiv.innerHTML = `<strong>Error:</strong> ${message}`;
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary');
|
||
|
||
this.modalBody.appendChild(errorDiv);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_showExtensionRequired() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Browser Extension Required';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const message = document.createElement('p');
|
||
message.innerHTML = `
|
||
Please install a Nostr browser extension and refresh the page.<br><br>
|
||
<strong>Important:</strong> If you have multiple extensions installed, please disable all but one to avoid conflicts.
|
||
<br><br>
|
||
Popular extensions: Alby, nos2x, Flamingo
|
||
`;
|
||
message.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px; line-height: 1.4;';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary');
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(message);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_getOrCreateNSignerCallerIdentity() {
|
||
const storageKey = 'nostr_login_lite_nsigner_caller';
|
||
const existing = localStorage.getItem(storageKey);
|
||
|
||
if (existing) {
|
||
try {
|
||
const parsed = JSON.parse(existing);
|
||
if (parsed?.secret && parsed?.pubkey) {
|
||
return parsed;
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
if (!window.NSignerWebUSB) {
|
||
throw new Error('NSignerWebUSB driver not loaded');
|
||
}
|
||
|
||
const secret = window.NSignerWebUSB.randomSecretHex();
|
||
const pubkey = window.NSignerWebUSB.toPubkeyHex(secret);
|
||
const generated = { secret, pubkey };
|
||
localStorage.setItem(storageKey, JSON.stringify(generated));
|
||
return generated;
|
||
}
|
||
|
||
async _promptNSignerImmediate() {
|
||
const secure = typeof window !== 'undefined' ? window.isSecureContext : false;
|
||
const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator);
|
||
|
||
if (!secure || !hasUsb) return;
|
||
|
||
try {
|
||
// Incremental step 1:
|
||
// chooser + capture + driver open only (no getPublicKey/auth yet).
|
||
const selectedDevice = await navigator.usb.requestDevice({ filters: [{}] });
|
||
this._nsignerSelectedDevice = selectedDevice;
|
||
|
||
if (!window.NSignerWebUSB || typeof window.NSignerWebUSB !== 'function') {
|
||
throw new Error('NSignerWebUSB driver missing');
|
||
}
|
||
|
||
const caller = this._getOrCreateNSignerCallerIdentity();
|
||
const driver = new window.NSignerWebUSB(selectedDevice, {
|
||
callerSecretKey: caller.secret,
|
||
nostrIndex: 0
|
||
});
|
||
|
||
await driver.open();
|
||
this._nsignerDebugDriver = driver;
|
||
|
||
console.info('NSigner USB open ok:', {
|
||
vendorId: driver.vendorId,
|
||
productId: driver.productId,
|
||
serialNumber: driver.serial || null,
|
||
isOpen: driver.isOpen
|
||
});
|
||
|
||
// Incremental step 2: get pubkey.
|
||
console.info('NSigner getPublicKey starting...');
|
||
const pubkey = await driver.getPublicKey();
|
||
this._nsignerDebugPubkey = pubkey;
|
||
console.info('NSigner getPublicKey ok:', { pubkey });
|
||
|
||
// Incremental step 3: finalize auth from the proven-good driver + pubkey.
|
||
driver.onDisconnect(() => {
|
||
window.dispatchEvent(new CustomEvent('nlReconnectionRequired', {
|
||
detail: {
|
||
method: 'nsigner',
|
||
pubkey,
|
||
connectionData: {
|
||
transport: 'webusb',
|
||
nostrIndex: 0,
|
||
callerSecretKey: caller.secret,
|
||
callerPubkey: caller.pubkey,
|
||
deviceVid: driver.vendorId,
|
||
deviceProductId: driver.productId,
|
||
deviceSerial: driver.serial
|
||
},
|
||
message: 'Your n_signer device was disconnected. Reconnect to continue.'
|
||
}
|
||
}));
|
||
});
|
||
|
||
this._setAuthMethod('nsigner', {
|
||
pubkey,
|
||
signer: {
|
||
driver,
|
||
transport: 'webusb',
|
||
nostrIndex: 0,
|
||
callerSecretKey: caller.secret,
|
||
callerPubkey: caller.pubkey,
|
||
deviceVid: driver.vendorId,
|
||
deviceProductId: driver.productId,
|
||
deviceSerial: driver.serial
|
||
}
|
||
});
|
||
} catch (error) {
|
||
// Explicit, high-signal logging for incremental diagnosis.
|
||
console.error('NSigner incremental flow failed:', error);
|
||
if (error && error.stack) console.error(error.stack);
|
||
}
|
||
}
|
||
|
||
_showHardwareSignerScreen({ autoPrompt = false } = {}) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'USB Hardware Signer';
|
||
title.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Connect your hardware signer. We auto-detect Web Serial first, then WebUSB if needed.';
|
||
description.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;';
|
||
|
||
const status = document.createElement('div');
|
||
status.style.cssText = 'margin-bottom: 12px; font-size: 12px; color: #6b7280;';
|
||
|
||
const secure = typeof window !== 'undefined' ? window.isSecureContext : false;
|
||
const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator);
|
||
const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator);
|
||
const canSerial = secure && hasSerial && typeof window.NSignerWebSerial === 'function';
|
||
const canUsb = secure && hasUsb && typeof window.NSignerWebUSB === 'function';
|
||
|
||
if (!secure || (!canSerial && !canUsb)) {
|
||
status.textContent = !secure
|
||
? 'Requires HTTPS or localhost.'
|
||
: 'No supported hardware transport detected (need Web Serial or WebUSB).';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary');
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(status);
|
||
this.modalBody.appendChild(backButton);
|
||
return;
|
||
}
|
||
|
||
const connectButton = document.createElement('button');
|
||
connectButton.textContent = 'Connect Hardware Signer';
|
||
connectButton.style.cssText = this._getButtonStyle();
|
||
|
||
const accountList = document.createElement('div');
|
||
accountList.style.cssText = 'margin-top: 12px;';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px;';
|
||
|
||
const shortHex = (v) => `${v.slice(0, 12)}...${v.slice(-8)}`;
|
||
const prePromptDisplay = this.container.style.display;
|
||
let hiddenForChooser = false;
|
||
|
||
const isChooserCancel = (err) => {
|
||
const msg = String(err?.message || err || '');
|
||
return msg.includes('NotFoundError') || msg.includes('No port selected') || msg.includes('No device selected');
|
||
};
|
||
|
||
const restoreModalVisibility = () => {
|
||
if (hiddenForChooser && this.isVisible) {
|
||
this.container.style.display = prePromptDisplay || 'block';
|
||
hiddenForChooser = false;
|
||
}
|
||
};
|
||
|
||
const renderTransportChoiceScreen = (note) => {
|
||
restoreModalVisibility();
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const choiceTitle = document.createElement('h3');
|
||
choiceTitle.textContent = 'USB Hardware Signer';
|
||
choiceTitle.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);';
|
||
this.modalBody.appendChild(choiceTitle);
|
||
|
||
const choiceDescription = document.createElement('p');
|
||
choiceDescription.textContent = note || 'Choose which transport your hardware signer uses:';
|
||
choiceDescription.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;';
|
||
this.modalBody.appendChild(choiceDescription);
|
||
|
||
if (canSerial) {
|
||
const serialButton = document.createElement('button');
|
||
serialButton.textContent = 'Try Web Serial Again';
|
||
serialButton.style.cssText = this._getButtonStyle() + 'margin-bottom: 10px;';
|
||
serialButton.onclick = () => connectNow({ forceTransport: 'webserial', viaUserGesture: true });
|
||
this.modalBody.appendChild(serialButton);
|
||
}
|
||
|
||
if (canUsb) {
|
||
const usbButton = document.createElement('button');
|
||
usbButton.textContent = 'Try WebUSB Instead';
|
||
usbButton.style.cssText = this._getButtonStyle() + 'margin-bottom: 10px;';
|
||
usbButton.onclick = () => connectNow({ forceTransport: 'webusb', viaUserGesture: true });
|
||
this.modalBody.appendChild(usbButton);
|
||
}
|
||
|
||
const choiceStatus = document.createElement('div');
|
||
choiceStatus.style.cssText = 'margin: 10px 0; font-size: 12px; color: #6b7280;';
|
||
choiceStatus.id = 'nl-hwsigner-choice-status';
|
||
this.modalBody.appendChild(choiceStatus);
|
||
|
||
const choiceBack = document.createElement('button');
|
||
choiceBack.textContent = 'Back';
|
||
choiceBack.onclick = () => this._renderLoginOptions();
|
||
choiceBack.style.cssText = this._getButtonStyle('secondary');
|
||
this.modalBody.appendChild(choiceBack);
|
||
};
|
||
|
||
const renderConnectingScreen = (note) => {
|
||
restoreModalVisibility();
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const connTitle = document.createElement('h3');
|
||
connTitle.textContent = 'USB Hardware Signer';
|
||
connTitle.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);';
|
||
this.modalBody.appendChild(connTitle);
|
||
|
||
const connStatus = document.createElement('p');
|
||
connStatus.textContent = note || 'Connecting...';
|
||
connStatus.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;';
|
||
this.modalBody.appendChild(connStatus);
|
||
};
|
||
|
||
const probeAccounts = async (driver, label) => {
|
||
const accounts = [];
|
||
for (let i = 0; i < 6; i++) {
|
||
try {
|
||
driver.nostrIndex = i;
|
||
console.info(`[${label}] probe index`, i);
|
||
const pubkey = await driver.getPublicKey();
|
||
if (/^[0-9a-f]{64}$/.test(pubkey) && !accounts.find(a => a.pubkey === pubkey)) {
|
||
accounts.push({ index: i, pubkey });
|
||
}
|
||
} catch (e) {
|
||
console.warn(`[${label}] account probe failed for index`, i, ':', e?.message || e);
|
||
}
|
||
}
|
||
|
||
if (!accounts.length) {
|
||
throw new Error('No account pubkeys returned by signer');
|
||
}
|
||
|
||
return accounts;
|
||
};
|
||
|
||
const renderAccountButtons = (connected) => {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const transportLabel = connected.transport === 'webserial' ? 'Web Serial' : 'WebUSB';
|
||
|
||
const selectionDescription = document.createElement('p');
|
||
selectionDescription.textContent = `Connected via ${transportLabel}. Select which account to use (${connected.accounts.length} discovered):`;
|
||
selectionDescription.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
|
||
this.modalBody.appendChild(selectionDescription);
|
||
|
||
const table = document.createElement('table');
|
||
table.style.cssText = `
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-bottom: 20px;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
font-size: 12px;
|
||
`;
|
||
|
||
const thead = document.createElement('thead');
|
||
thead.innerHTML = `
|
||
<tr style="background: #f3f4f6;">
|
||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
|
||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
|
||
</tr>
|
||
`;
|
||
table.appendChild(thead);
|
||
|
||
const tbody = document.createElement('tbody');
|
||
connected.accounts.forEach((acct) => {
|
||
const row = document.createElement('tr');
|
||
row.style.cssText = 'border: 1px solid #d1d5db;';
|
||
|
||
const indexCell = document.createElement('td');
|
||
indexCell.textContent = acct.index;
|
||
indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;';
|
||
|
||
const actionCell = document.createElement('td');
|
||
actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;';
|
||
|
||
const selectButton = document.createElement('button');
|
||
selectButton.textContent = shortHex(acct.pubkey);
|
||
selectButton.style.cssText = `
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
font-size: 11px;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: 1px solid var(--nl-primary-color);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-family: 'Courier New', monospace;
|
||
text-align: center;
|
||
`;
|
||
selectButton.onmouseover = () => { selectButton.style.borderColor = 'var(--nl-accent-color)'; };
|
||
selectButton.onmouseout = () => { selectButton.style.borderColor = 'var(--nl-primary-color)'; };
|
||
selectButton.onclick = () => {
|
||
connected.driver.nostrIndex = acct.index;
|
||
this._setAuthMethod('nsigner', {
|
||
pubkey: acct.pubkey,
|
||
signer: {
|
||
driver: connected.driver,
|
||
transport: connected.transport,
|
||
nostrIndex: acct.index,
|
||
callerSecretKey: connected.callerSecretKey,
|
||
callerPubkey: connected.callerPubkey,
|
||
deviceVid: connected.deviceVid,
|
||
deviceProductId: connected.deviceProductId,
|
||
deviceSerial: connected.deviceSerial
|
||
}
|
||
});
|
||
};
|
||
|
||
actionCell.appendChild(selectButton);
|
||
row.appendChild(indexCell);
|
||
row.appendChild(actionCell);
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
table.appendChild(tbody);
|
||
this.modalBody.appendChild(table);
|
||
|
||
const selectionBackButton = document.createElement('button');
|
||
selectionBackButton.textContent = 'Back';
|
||
selectionBackButton.onclick = () => this._renderLoginOptions();
|
||
selectionBackButton.style.cssText = this._getButtonStyle('secondary');
|
||
this.modalBody.appendChild(selectionBackButton);
|
||
};
|
||
|
||
const connectNow = async ({ forceTransport = null, viaUserGesture = false } = {}) => {
|
||
try {
|
||
const caller = this._getOrCreateNSignerCallerIdentity();
|
||
const startIndex = 0;
|
||
|
||
const connectSerialFromPort = async (port) => {
|
||
const driver = new window.NSignerWebSerial(port, {
|
||
callerSecretKey: caller.secret,
|
||
nostrIndex: startIndex
|
||
});
|
||
await driver.open();
|
||
return driver;
|
||
};
|
||
|
||
const connectUsbFromDevice = async (device) => {
|
||
const driver = new window.NSignerWebUSB(device, {
|
||
callerSecretKey: caller.secret,
|
||
nostrIndex: startIndex
|
||
});
|
||
await driver.open();
|
||
return driver;
|
||
};
|
||
|
||
let transport = null;
|
||
let driver = null;
|
||
|
||
// Try silent paired-device reconnect first when this isn't a transport-forced retry.
|
||
if (!forceTransport) {
|
||
if (canSerial) {
|
||
try {
|
||
const pairedSerial = await window.NSignerWebSerial.getPairedDevice({});
|
||
if (pairedSerial) {
|
||
renderConnectingScreen('Reconnecting to paired Web Serial device...');
|
||
transport = 'webserial';
|
||
driver = await connectSerialFromPort(pairedSerial);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Paired Web Serial reconnect failed:', e);
|
||
}
|
||
}
|
||
|
||
if (!driver && canUsb) {
|
||
try {
|
||
const pairedUsb = await window.NSignerWebUSB.getPairedDevice();
|
||
if (pairedUsb) {
|
||
renderConnectingScreen('Reconnecting to paired WebUSB device...');
|
||
transport = 'webusb';
|
||
driver = await connectUsbFromDevice(pairedUsb);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Paired WebUSB reconnect failed:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Need a new chooser path.
|
||
if (!driver) {
|
||
// Determine which transport to prompt. Default to Web Serial when available.
|
||
const target = forceTransport
|
||
|| (canSerial ? 'webserial' : (canUsb ? 'webusb' : null));
|
||
|
||
if (!target) {
|
||
throw new Error('No supported hardware transport available');
|
||
}
|
||
|
||
if (target === 'webserial') {
|
||
renderConnectingScreen('Waiting for Web Serial port selection...');
|
||
try {
|
||
driver = await window.NSignerWebSerial.requestAndConnect({
|
||
callerSecretKey: caller.secret,
|
||
nostrIndex: startIndex
|
||
});
|
||
transport = 'webserial';
|
||
} catch (serialErr) {
|
||
if (isChooserCancel(serialErr)) {
|
||
renderTransportChoiceScreen(
|
||
canUsb
|
||
? 'No serial port selected. Choose how to connect:'
|
||
: 'No serial port selected.'
|
||
);
|
||
return;
|
||
}
|
||
throw serialErr;
|
||
}
|
||
} else if (target === 'webusb') {
|
||
renderConnectingScreen('Waiting for WebUSB device selection...');
|
||
try {
|
||
const selectedDevice = await navigator.usb.requestDevice({ filters: [{}] });
|
||
transport = 'webusb';
|
||
driver = await connectUsbFromDevice(selectedDevice);
|
||
} catch (usbErr) {
|
||
if (isChooserCancel(usbErr)) {
|
||
renderTransportChoiceScreen(
|
||
canSerial
|
||
? 'No USB device selected. Choose how to connect:'
|
||
: 'No USB device selected.'
|
||
);
|
||
return;
|
||
}
|
||
throw usbErr;
|
||
}
|
||
}
|
||
}
|
||
|
||
renderConnectingScreen('Connected. Reading account pubkeys...');
|
||
const accounts = await probeAccounts(driver, transport);
|
||
|
||
const serialInfo = transport === 'webserial' ? (driver._port?.getInfo?.() || {}) : null;
|
||
const connected = {
|
||
transport,
|
||
accounts,
|
||
callerSecretKey: caller.secret,
|
||
callerPubkey: caller.pubkey,
|
||
driver,
|
||
deviceVid: transport === 'webserial' ? (serialInfo.usbVendorId ?? null) : driver.vendorId,
|
||
deviceProductId: transport === 'webserial' ? (serialInfo.usbProductId ?? null) : driver.productId,
|
||
deviceSerial: transport === 'webserial' ? null : (driver.serial || null)
|
||
};
|
||
|
||
driver.onDisconnect(() => {
|
||
window.dispatchEvent(new CustomEvent('nlReconnectionRequired', {
|
||
detail: {
|
||
method: 'nsigner',
|
||
pubkey: accounts[0].pubkey,
|
||
connectionData: {
|
||
transport,
|
||
nostrIndex: accounts[0].index,
|
||
callerSecretKey: caller.secret,
|
||
callerPubkey: caller.pubkey,
|
||
deviceVid: connected.deviceVid,
|
||
deviceProductId: connected.deviceProductId,
|
||
deviceSerial: connected.deviceSerial
|
||
},
|
||
message: transport === 'webserial'
|
||
? 'Your hardware signer (Web Serial) was disconnected. Reconnect to continue.'
|
||
: 'Your hardware signer (WebUSB) was disconnected. Reconnect to continue.'
|
||
}
|
||
}));
|
||
});
|
||
|
||
restoreModalVisibility();
|
||
renderAccountButtons(connected);
|
||
} catch (error) {
|
||
const msg = String(error?.message || error);
|
||
let displayMsg;
|
||
if (msg.includes('NetworkError')) {
|
||
displayMsg = 'Device/port is already in use — close any serial monitor and try again.';
|
||
} else if (msg.includes('SecurityError')) {
|
||
displayMsg = 'Access denied. Check browser permissions and Linux udev/dialout setup.';
|
||
} else if (msg.includes('NotFoundError')) {
|
||
displayMsg = 'No device selected.';
|
||
} else {
|
||
displayMsg = `Connect failed: ${msg}`;
|
||
}
|
||
|
||
// Always re-surface a usable UI on failure.
|
||
renderTransportChoiceScreen(displayMsg);
|
||
}
|
||
};
|
||
|
||
connectButton.onclick = () => connectNow();
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(connectButton);
|
||
this.modalBody.appendChild(status);
|
||
this.modalBody.appendChild(accountList);
|
||
this.modalBody.appendChild(backButton);
|
||
|
||
if (autoPrompt) {
|
||
hiddenForChooser = true;
|
||
this.container.style.display = 'none';
|
||
connectNow();
|
||
return;
|
||
}
|
||
}
|
||
|
||
_showNSignerScreen({ autoPrompt = false } = {}) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'USB Hardware signer';
|
||
title.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Select your USB n_signer device when prompted, then choose one of the discovered accounts.';
|
||
description.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;';
|
||
|
||
const status = document.createElement('div');
|
||
status.style.cssText = 'margin-bottom: 12px; font-size: 12px; color: #6b7280;';
|
||
|
||
const secure = typeof window !== 'undefined' ? window.isSecureContext : false;
|
||
const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator);
|
||
|
||
if (!secure || !hasUsb) {
|
||
status.textContent = !secure
|
||
? 'Requires HTTPS or localhost for WebUSB.'
|
||
: 'WebUSB unavailable. Use Chrome or Edge.';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary');
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(status);
|
||
this.modalBody.appendChild(backButton);
|
||
return;
|
||
}
|
||
|
||
const connectButton = document.createElement('button');
|
||
connectButton.textContent = 'Choose Device';
|
||
connectButton.style.cssText = this._getButtonStyle();
|
||
|
||
const accountList = document.createElement('div');
|
||
accountList.style.cssText = 'margin-top: 12px;';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px;';
|
||
|
||
const shortHex = (v) => `${v.slice(0, 12)}...${v.slice(-8)}`;
|
||
const prePromptDisplay = this.container.style.display;
|
||
let hiddenForChooser = false;
|
||
|
||
const renderAccountButtons = (connected) => {
|
||
// Once device is selected and accounts are loaded, switch to pure account selection UI
|
||
// matching the seed phrase flow (no reconnect header/buttons).
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const selectionDescription = document.createElement('p');
|
||
selectionDescription.textContent = `Select which account to use (${connected.accounts.length} accounts discovered from hardware signer):`;
|
||
selectionDescription.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
|
||
this.modalBody.appendChild(selectionDescription);
|
||
|
||
const table = document.createElement('table');
|
||
table.style.cssText = `
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-bottom: 20px;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
font-size: 12px;
|
||
`;
|
||
|
||
const thead = document.createElement('thead');
|
||
thead.innerHTML = `
|
||
<tr style="background: #f3f4f6;">
|
||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
|
||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
|
||
</tr>
|
||
`;
|
||
table.appendChild(thead);
|
||
|
||
const tbody = document.createElement('tbody');
|
||
connected.accounts.forEach((acct) => {
|
||
const row = document.createElement('tr');
|
||
row.style.cssText = 'border: 1px solid #d1d5db;';
|
||
|
||
const indexCell = document.createElement('td');
|
||
indexCell.textContent = acct.index;
|
||
indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;';
|
||
|
||
const actionCell = document.createElement('td');
|
||
actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;';
|
||
|
||
const selectButton = document.createElement('button');
|
||
selectButton.textContent = shortHex(acct.pubkey);
|
||
selectButton.style.cssText = `
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
font-size: 11px;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: 1px solid var(--nl-primary-color);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-family: 'Courier New', monospace;
|
||
text-align: center;
|
||
`;
|
||
selectButton.onmouseover = () => {
|
||
selectButton.style.borderColor = 'var(--nl-accent-color)';
|
||
};
|
||
selectButton.onmouseout = () => {
|
||
selectButton.style.borderColor = 'var(--nl-primary-color)';
|
||
};
|
||
selectButton.onclick = () => {
|
||
connected.driver.nostrIndex = acct.index;
|
||
this._setAuthMethod('nsigner', {
|
||
pubkey: acct.pubkey,
|
||
signer: {
|
||
driver: connected.driver,
|
||
transport: 'webusb',
|
||
nostrIndex: acct.index,
|
||
callerSecretKey: connected.callerSecretKey,
|
||
callerPubkey: connected.callerPubkey,
|
||
deviceVid: connected.deviceVid,
|
||
deviceProductId: connected.deviceProductId,
|
||
deviceSerial: connected.deviceSerial
|
||
}
|
||
});
|
||
};
|
||
|
||
actionCell.appendChild(selectButton);
|
||
row.appendChild(indexCell);
|
||
row.appendChild(actionCell);
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
table.appendChild(tbody);
|
||
this.modalBody.appendChild(table);
|
||
|
||
const selectionBackButton = document.createElement('button');
|
||
selectionBackButton.textContent = 'Back';
|
||
selectionBackButton.onclick = () => this._renderLoginOptions();
|
||
selectionBackButton.style.cssText = this._getButtonStyle('secondary');
|
||
this.modalBody.appendChild(selectionBackButton);
|
||
};
|
||
|
||
const connectNow = async () => {
|
||
try {
|
||
const startIndex = 0;
|
||
status.textContent = 'Connecting...';
|
||
accountList.innerHTML = '';
|
||
|
||
const caller = this._getOrCreateNSignerCallerIdentity();
|
||
|
||
if (!window.NSignerWebUSB || typeof window.NSignerWebUSB !== 'function') {
|
||
throw new Error('NSignerWebUSB driver missing');
|
||
}
|
||
|
||
const selectedDevice = await navigator.usb.requestDevice({ filters: [{}] });
|
||
|
||
const driver = new window.NSignerWebUSB(selectedDevice, {
|
||
callerSecretKey: caller.secret,
|
||
nostrIndex: startIndex
|
||
});
|
||
|
||
await driver.open();
|
||
|
||
status.textContent = 'Connected. Reading account pubkeys...';
|
||
const accounts = [];
|
||
for (let i = startIndex; i < startIndex + 6; i++) {
|
||
try {
|
||
driver.nostrIndex = i;
|
||
console.info('NSigner probe index', i, 'nostrIndex=', driver.nostrIndex);
|
||
const pubkey = await driver.getPublicKey();
|
||
console.info('NSigner probe index', i, 'pubkey=', pubkey);
|
||
if (/^[0-9a-f]{64}$/.test(pubkey) && !accounts.find(a => a.pubkey === pubkey)) {
|
||
accounts.push({ index: i, pubkey });
|
||
}
|
||
} catch (e) {
|
||
console.warn('NSigner account probe failed for index', i, ':', e?.message || e);
|
||
}
|
||
}
|
||
|
||
console.info('NSigner probe complete, accounts found:', accounts.length, accounts);
|
||
|
||
if (!accounts.length) {
|
||
throw new Error('No account pubkeys returned by signer');
|
||
}
|
||
|
||
const connected = {
|
||
accounts,
|
||
callerSecretKey: caller.secret,
|
||
callerPubkey: caller.pubkey,
|
||
driver,
|
||
deviceVid: driver.vendorId,
|
||
deviceProductId: driver.productId,
|
||
deviceSerial: driver.serial
|
||
};
|
||
|
||
driver.onDisconnect(() => {
|
||
window.dispatchEvent(new CustomEvent('nlReconnectionRequired', {
|
||
detail: {
|
||
method: 'nsigner',
|
||
pubkey: accounts[0].pubkey,
|
||
connectionData: {
|
||
transport: 'webusb',
|
||
nostrIndex: accounts[0].index,
|
||
callerSecretKey: caller.secret,
|
||
callerPubkey: caller.pubkey,
|
||
deviceVid: driver.vendorId,
|
||
deviceProductId: driver.productId,
|
||
deviceSerial: driver.serial
|
||
},
|
||
message: 'Your n_signer device was disconnected. Reconnect to continue.'
|
||
}
|
||
}));
|
||
});
|
||
|
||
// Ensure modal is visible before rendering account selection.
|
||
if (hiddenForChooser && this.isVisible) {
|
||
this.container.style.display = prePromptDisplay || 'block';
|
||
hiddenForChooser = false;
|
||
}
|
||
renderAccountButtons(connected);
|
||
} catch (error) {
|
||
const msg = String(error?.message || error);
|
||
if (msg.includes('NotFoundError')) {
|
||
status.textContent = 'No USB device selected.';
|
||
} else if (msg.includes('SecurityError')) {
|
||
status.textContent = 'Access denied. On Linux, install the udev rule for VID:PID 303a:4001 and replug.';
|
||
} else {
|
||
status.textContent = `Connect failed: ${msg}`;
|
||
}
|
||
}
|
||
|
||
// If chooser was hidden and we failed before account list render,
|
||
// bring modal back with status text instead of leaving blank body.
|
||
if (hiddenForChooser && this.isVisible) {
|
||
this.container.style.display = prePromptDisplay || 'block';
|
||
hiddenForChooser = false;
|
||
}
|
||
};
|
||
|
||
connectButton.onclick = connectNow;
|
||
|
||
// Keep scaffold consistent so auto-prompt failures still show status/back UI.
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(connectButton);
|
||
this.modalBody.appendChild(status);
|
||
this.modalBody.appendChild(accountList);
|
||
this.modalBody.appendChild(backButton);
|
||
|
||
// For tile-click flow, hide modal only while chooser is visible.
|
||
if (autoPrompt) {
|
||
hiddenForChooser = true;
|
||
this.container.style.display = 'none';
|
||
connectNow();
|
||
return;
|
||
}
|
||
}
|
||
|
||
_showCydSerialScreen({ autoPrompt = false } = {}) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'CYD Serial Hardware Signer';
|
||
title.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Select your CYD (ESP32-2432S028) serial port when prompted, then choose one of the discovered accounts.';
|
||
description.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;';
|
||
|
||
const status = document.createElement('div');
|
||
status.style.cssText = 'margin-bottom: 12px; font-size: 12px; color: #6b7280;';
|
||
|
||
const secure = typeof window !== 'undefined' ? window.isSecureContext : false;
|
||
const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator);
|
||
|
||
if (!secure || !hasSerial) {
|
||
status.textContent = !secure
|
||
? 'Requires HTTPS or localhost for Web Serial.'
|
||
: 'Web Serial unavailable. Use Chrome 89+, Edge 89+, or Brave.';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary');
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(status);
|
||
this.modalBody.appendChild(backButton);
|
||
return;
|
||
}
|
||
|
||
const connectButton = document.createElement('button');
|
||
connectButton.textContent = 'Choose Serial Port';
|
||
connectButton.style.cssText = this._getButtonStyle();
|
||
|
||
const accountList = document.createElement('div');
|
||
accountList.style.cssText = 'margin-top: 12px;';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px;';
|
||
|
||
const shortHex = (v) => `${v.slice(0, 12)}...${v.slice(-8)}`;
|
||
const prePromptDisplay = this.container.style.display;
|
||
let hiddenForChooser = false;
|
||
|
||
const renderAccountButtons = (connected) => {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const selectionDescription = document.createElement('p');
|
||
selectionDescription.textContent = `Select which account to use (${connected.accounts.length} accounts discovered from CYD signer):`;
|
||
selectionDescription.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
|
||
this.modalBody.appendChild(selectionDescription);
|
||
|
||
const table = document.createElement('table');
|
||
table.style.cssText = `
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-bottom: 20px;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
font-size: 12px;
|
||
`;
|
||
|
||
const thead = document.createElement('thead');
|
||
thead.innerHTML = `
|
||
<tr style="background: #f3f4f6;">
|
||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
|
||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
|
||
</tr>
|
||
`;
|
||
table.appendChild(thead);
|
||
|
||
const tbody = document.createElement('tbody');
|
||
connected.accounts.forEach((acct) => {
|
||
const row = document.createElement('tr');
|
||
row.style.cssText = 'border: 1px solid #d1d5db;';
|
||
|
||
const indexCell = document.createElement('td');
|
||
indexCell.textContent = acct.index;
|
||
indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;';
|
||
|
||
const actionCell = document.createElement('td');
|
||
actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;';
|
||
|
||
const selectButton = document.createElement('button');
|
||
selectButton.textContent = shortHex(acct.pubkey);
|
||
selectButton.style.cssText = `
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
font-size: 11px;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: 1px solid var(--nl-primary-color);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-family: 'Courier New', monospace;
|
||
text-align: center;
|
||
`;
|
||
selectButton.onmouseover = () => { selectButton.style.borderColor = 'var(--nl-accent-color)'; };
|
||
selectButton.onmouseout = () => { selectButton.style.borderColor = 'var(--nl-primary-color)'; };
|
||
selectButton.onclick = () => {
|
||
connected.driver.nostrIndex = acct.index;
|
||
this._setAuthMethod('nsigner', {
|
||
pubkey: acct.pubkey,
|
||
signer: {
|
||
driver: connected.driver,
|
||
transport: 'webserial',
|
||
nostrIndex: acct.index,
|
||
callerSecretKey: connected.callerSecretKey,
|
||
callerPubkey: connected.callerPubkey,
|
||
deviceVid: connected.deviceVid,
|
||
deviceProductId: connected.deviceProductId,
|
||
deviceSerial: null
|
||
}
|
||
});
|
||
};
|
||
|
||
actionCell.appendChild(selectButton);
|
||
row.appendChild(indexCell);
|
||
row.appendChild(actionCell);
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
table.appendChild(tbody);
|
||
this.modalBody.appendChild(table);
|
||
|
||
const selectionBackButton = document.createElement('button');
|
||
selectionBackButton.textContent = 'Back';
|
||
selectionBackButton.onclick = () => this._renderLoginOptions();
|
||
selectionBackButton.style.cssText = this._getButtonStyle('secondary');
|
||
this.modalBody.appendChild(selectionBackButton);
|
||
};
|
||
|
||
const connectNow = async () => {
|
||
try {
|
||
const startIndex = 0;
|
||
status.textContent = 'Connecting...';
|
||
accountList.innerHTML = '';
|
||
|
||
const caller = this._getOrCreateNSignerCallerIdentity();
|
||
|
||
if (!window.NSignerWebSerial || typeof window.NSignerWebSerial !== 'function') {
|
||
throw new Error('NSignerWebSerial driver missing');
|
||
}
|
||
|
||
const driver = await window.NSignerWebSerial.requestAndConnect({
|
||
callerSecretKey: caller.secret,
|
||
nostrIndex: startIndex
|
||
});
|
||
|
||
status.textContent = 'Connected. Reading account pubkeys...';
|
||
const accounts = [];
|
||
for (let i = startIndex; i < startIndex + 6; i++) {
|
||
try {
|
||
driver.nostrIndex = i;
|
||
console.info('NSignerWebSerial probe index', i);
|
||
const pubkey = await driver.getPublicKey();
|
||
console.info('NSignerWebSerial probe index', i, 'pubkey=', pubkey);
|
||
if (/^[0-9a-f]{64}$/.test(pubkey) && !accounts.find(a => a.pubkey === pubkey)) {
|
||
accounts.push({ index: i, pubkey });
|
||
}
|
||
} catch (e) {
|
||
console.warn('NSignerWebSerial account probe failed for index', i, ':', e?.message || e);
|
||
}
|
||
}
|
||
|
||
console.info('NSignerWebSerial probe complete, accounts found:', accounts.length, accounts);
|
||
|
||
if (!accounts.length) {
|
||
throw new Error('No account pubkeys returned by CYD signer');
|
||
}
|
||
|
||
const info = driver._port?.getInfo?.() || {};
|
||
const connected = {
|
||
accounts,
|
||
callerSecretKey: caller.secret,
|
||
callerPubkey: caller.pubkey,
|
||
driver,
|
||
deviceVid: info.usbVendorId ?? null,
|
||
deviceProductId: info.usbProductId ?? null
|
||
};
|
||
|
||
driver.onDisconnect(() => {
|
||
window.dispatchEvent(new CustomEvent('nlReconnectionRequired', {
|
||
detail: {
|
||
method: 'nsigner',
|
||
pubkey: accounts[0].pubkey,
|
||
connectionData: {
|
||
transport: 'webserial',
|
||
nostrIndex: accounts[0].index,
|
||
callerSecretKey: caller.secret,
|
||
callerPubkey: caller.pubkey,
|
||
deviceVid: connected.deviceVid,
|
||
deviceProductId: connected.deviceProductId,
|
||
deviceSerial: null
|
||
},
|
||
message: 'Your CYD n_signer device was disconnected. Reconnect to continue.'
|
||
}
|
||
}));
|
||
});
|
||
|
||
if (hiddenForChooser && this.isVisible) {
|
||
this.container.style.display = prePromptDisplay || 'block';
|
||
hiddenForChooser = false;
|
||
}
|
||
renderAccountButtons(connected);
|
||
} catch (error) {
|
||
const msg = String(error?.message || error);
|
||
if (msg.includes('NotFoundError') || msg.includes('No port selected')) {
|
||
status.textContent = 'No serial port selected.';
|
||
} else if (msg.includes('NetworkError')) {
|
||
status.textContent = 'Port already in use — close any serial monitor and try again.';
|
||
} else if (msg.includes('SecurityError')) {
|
||
status.textContent = 'Access denied. On Linux, add yourself to the dialout group and replug.';
|
||
} else {
|
||
status.textContent = `Connect failed: ${msg}`;
|
||
}
|
||
}
|
||
|
||
if (hiddenForChooser && this.isVisible) {
|
||
this.container.style.display = prePromptDisplay || 'block';
|
||
hiddenForChooser = false;
|
||
}
|
||
};
|
||
|
||
connectButton.onclick = connectNow;
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(connectButton);
|
||
this.modalBody.appendChild(status);
|
||
this.modalBody.appendChild(accountList);
|
||
this.modalBody.appendChild(backButton);
|
||
|
||
if (autoPrompt) {
|
||
hiddenForChooser = true;
|
||
this.container.style.display = 'none';
|
||
connectNow();
|
||
return;
|
||
}
|
||
}
|
||
|
||
_showConnectScreen() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Connect to a remote signer (bunker) server to use its keys for signing.';
|
||
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
|
||
|
||
const formGroup = document.createElement('div');
|
||
formGroup.style.cssText = 'margin-bottom: 20px;';
|
||
|
||
const label = document.createElement('label');
|
||
label.textContent = 'Bunker Public Key:';
|
||
label.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
|
||
|
||
const pubkeyInput = document.createElement('input');
|
||
pubkeyInput.type = 'text';
|
||
pubkeyInput.placeholder = 'bunker://pubkey?relay=..., bunker:hex, hex, or npub...';
|
||
pubkeyInput.style.cssText = `
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
font-family: monospace;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
// Add real-time bunker key validation
|
||
const formatHint = document.createElement('div');
|
||
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
|
||
|
||
const connectButton = document.createElement('button');
|
||
connectButton.textContent = 'Connect to Bunker';
|
||
connectButton.disabled = true;
|
||
connectButton.onclick = () => {
|
||
if (!connectButton.disabled) {
|
||
this._handleNip46Connect(pubkeyInput.value);
|
||
}
|
||
};
|
||
|
||
// Set initial disabled state
|
||
connectButton.style.cssText = `
|
||
display: block;
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: var(--nl-border-width) solid var(--nl-muted-color);
|
||
border-radius: var(--nl-border-radius);
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
cursor: not-allowed;
|
||
transition: all 0.2s;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-muted-color);
|
||
margin-bottom: 12px;
|
||
`;
|
||
|
||
pubkeyInput.oninput = () => {
|
||
const value = pubkeyInput.value.trim();
|
||
if (!value) {
|
||
formatHint.textContent = '';
|
||
// Disable button
|
||
connectButton.disabled = true;
|
||
connectButton.style.borderColor = 'var(--nl-muted-color)';
|
||
connectButton.style.color = 'var(--nl-muted-color)';
|
||
connectButton.style.cursor = 'not-allowed';
|
||
return;
|
||
}
|
||
|
||
const isValid = this._validateBunkerKey(value);
|
||
if (isValid) {
|
||
formatHint.textContent = '✅ Valid bunker connection format detected';
|
||
formatHint.style.color = '#059669';
|
||
// Enable button
|
||
connectButton.disabled = false;
|
||
connectButton.style.borderColor = 'var(--nl-primary-color)';
|
||
connectButton.style.color = 'var(--nl-primary-color)';
|
||
connectButton.style.cursor = 'pointer';
|
||
} else {
|
||
formatHint.textContent = '❌ Invalid format - must be bunker://, npub, or 64-char hex';
|
||
formatHint.style.color = '#dc2626';
|
||
// Disable button
|
||
connectButton.disabled = true;
|
||
connectButton.style.borderColor = 'var(--nl-muted-color)';
|
||
connectButton.style.color = 'var(--nl-muted-color)';
|
||
connectButton.style.cursor = 'not-allowed';
|
||
}
|
||
};
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
formGroup.appendChild(label);
|
||
formGroup.appendChild(pubkeyInput);
|
||
formGroup.appendChild(formatHint);
|
||
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(formGroup);
|
||
this.modalBody.appendChild(connectButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_validateBunkerKey(bunkerKey) {
|
||
try {
|
||
const trimmed = bunkerKey.trim();
|
||
|
||
// Check for bunker:// format
|
||
if (trimmed.startsWith('bunker://')) {
|
||
// Should have format: bunker://pubkey or bunker://pubkey?param=value
|
||
const match = trimmed.match(/^bunker:\/\/([0-9a-fA-F]{64})(\?.*)?$/);
|
||
return !!match;
|
||
}
|
||
|
||
// Check for npub format
|
||
if (trimmed.startsWith('npub1') && trimmed.length === 63) {
|
||
try {
|
||
if (window.NostrTools?.nip19) {
|
||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||
return decoded.type === 'npub';
|
||
}
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Check for hex format (64 characters, valid hex)
|
||
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
} catch (error) {
|
||
// console.log('Bunker key validation failed:', error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
_handleNip46Connect(bunkerPubkey) {
|
||
if (!bunkerPubkey || !bunkerPubkey.length) {
|
||
this._showError('Bunker pubkey is required');
|
||
return;
|
||
}
|
||
|
||
this._showNip46Connecting(bunkerPubkey);
|
||
|
||
// While connecting, prevent modal.open() from overwriting the "Connecting..."
|
||
// state (e.g. when init-ndk's auth timeout fires and calls launch() again).
|
||
this._nip46Connecting = true;
|
||
this._performNip46Connect(bunkerPubkey).finally(() => {
|
||
this._nip46Connecting = false;
|
||
});
|
||
}
|
||
|
||
_showNip46Connecting(bunkerPubkey) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Connecting to Remote Signer...';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Establishing secure connection to your remote signer.';
|
||
description.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
|
||
|
||
// Normalize bunker pubkey for display (= show original format if bunker: prefix)
|
||
const displayPubkey = bunkerPubkey.startsWith('bunker:') || bunkerPubkey.startsWith('npub') || bunkerPubkey.length === 64 ? bunkerPubkey : bunkerPubkey;
|
||
|
||
const bunkerInfo = document.createElement('div');
|
||
bunkerInfo.style.cssText = 'background: #f1f5f9; padding: 12px; border-radius: 6px; margin-bottom: 20px; font-size: 14px;';
|
||
bunkerInfo.innerHTML = `
|
||
<strong>Connecting to bunker:</strong><br>
|
||
Connection: <code style="word-break: break-all;">${displayPubkey}</code><br>
|
||
<small style="color: #6b7280;">Connection string contains all necessary relay information.</small>
|
||
`;
|
||
|
||
const connectingDiv = document.createElement('div');
|
||
connectingDiv.style.cssText = 'text-align: center; color: #6b7280;';
|
||
connectingDiv.innerHTML = `
|
||
<div style="font-size: 24px; margin-bottom: 10px;">⏳</div>
|
||
<div>Please wait while we establish the connection...</div>
|
||
<div style="font-size: 12px; margin-top: 10px;">This may take a few seconds</div>
|
||
`;
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(bunkerInfo);
|
||
this.modalBody.appendChild(connectingDiv);
|
||
}
|
||
|
||
async _performNip46Connect(bunkerPubkey) {
|
||
// Cancel any previous in-flight connect attempt
|
||
if (this._nip46CancelToken) this._nip46CancelToken.cancelled = true;
|
||
const cancelToken = { cancelled: false };
|
||
this._nip46CancelToken = cancelToken;
|
||
|
||
try {
|
||
// Check if nostr-tools NIP-46 is available
|
||
if (!window.NostrTools?.nip46) {
|
||
throw new Error('nostr-tools NIP-46 module not available');
|
||
}
|
||
|
||
// Use nostr-tools to parse bunker input - this handles all formats correctly
|
||
const bunkerPointer = await window.NostrTools.nip46.parseBunkerInput(bunkerPubkey);
|
||
|
||
if (!bunkerPointer) {
|
||
throw new Error('Unable to parse bunker connection string or resolve NIP-05 identifier');
|
||
}
|
||
|
||
// Create local client keypair for this session
|
||
const localSecretKey = window.NostrTools.generateSecretKey();
|
||
|
||
// Create a SimplePool with a patched subscribe that filters out stale cached
|
||
// NIP-46 response events. Relays like primal.net store and replay previous
|
||
// "unauthorized" responses before we've even sent our request.
|
||
// We use a shared object so the flag can be set synchronously from outside
|
||
// the closure at exactly the right moment.
|
||
const sessionStart = Math.floor(Date.now() / 1000);
|
||
const gate = { open: false };
|
||
const signerRef = { current: null }; // forward ref so the closure can access signer after it's created
|
||
const pool = new window.NostrTools.SimplePool();
|
||
const origSubscribe = pool.subscribe.bind(pool);
|
||
pool.subscribe = (relays, filter, subParams) => {
|
||
const origOnevent = subParams.onevent;
|
||
subParams.onevent = async (event) => {
|
||
if (!gate.open) return; // drop events arriving before we open the gate
|
||
// Also skip if we can decrypt and see it's a response to a request id
|
||
// that we haven't sent yet (i.e. its id doesn't match any pending listener)
|
||
try {
|
||
const s = signerRef.current;
|
||
if (!s) return; // signer not yet created, drop
|
||
const decrypted = JSON.parse(window.NostrTools.nip44.decrypt(event.content, s.conversationKey));
|
||
if (decrypted.id && !s.listeners[decrypted.id]) {
|
||
// No pending listener for this id — it's stale, drop it
|
||
return;
|
||
}
|
||
} catch (_) {
|
||
// Can't decrypt — not for us, drop it
|
||
return;
|
||
}
|
||
return origOnevent(event);
|
||
};
|
||
return origSubscribe(relays, { ...filter, since: sessionStart }, subParams);
|
||
};
|
||
|
||
// Use nostr-tools BunkerSigner factory method, passing our patched pool
|
||
const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(localSecretKey, bunkerPointer, {
|
||
pool,
|
||
onauth: (url) => {
|
||
window.open(url, '_blank', 'width=600,height=800');
|
||
}
|
||
});
|
||
signerRef.current = signer; // allow the onevent closure to access the signer
|
||
|
||
// NIP-46 connect requires 3 params: [remotePubkey, secret, perms]
|
||
// The nostr-tools BunkerSigner.connect() only sends [pubkey, secret] (2 params),
|
||
// which causes strict bunkers to respond "unauthorized" for subsequent calls
|
||
// because no permissions were explicitly requested.
|
||
// We call sendRequest directly to include the required perms in the connect call.
|
||
const perms = bunkerPointer.perms && bunkerPointer.perms.length
|
||
? bunkerPointer.perms
|
||
: ['get_public_key', 'sign_event', 'nip04_encrypt', 'nip04_decrypt', 'nip44_encrypt', 'nip44_decrypt'];
|
||
|
||
// Some signers (e.g. Amber) require the user to approve the connection on their
|
||
// device before responding with "ack". They respond "unauthorized" immediately
|
||
// and send "ack" once the user taps approve. We retry the connect request on
|
||
// "unauthorized" until the user approves or the overall timeout is reached.
|
||
const CONNECT_TIMEOUT_MS = 120000; // 2 minutes for user to approve on device
|
||
const RETRY_DELAY_MS = 3000;
|
||
const deadline = Date.now() + CONNECT_TIMEOUT_MS;
|
||
let connectError = null;
|
||
let connectDone = false;
|
||
while (!connectDone) {
|
||
try {
|
||
gate.open = true;
|
||
await signer.sendRequest('connect', [bunkerPointer.pubkey, bunkerPointer.secret || '', perms.join(',')]);
|
||
connectDone = true;
|
||
} catch (err) {
|
||
if (String(err) === 'unauthorized' && Date.now() < deadline && !cancelToken.cancelled) {
|
||
connectError = err;
|
||
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
|
||
// Only cancel during the retry wait, not after a successful connect
|
||
if (cancelToken.cancelled) return;
|
||
} else {
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get the user's public key from the bunker
|
||
console.log('[NIP-46] connect done, requesting getPublicKey...');
|
||
const userPubkey = await signer.getPublicKey();
|
||
console.log('[NIP-46] getPublicKey returned:', userPubkey);
|
||
|
||
// Store the NIP-46 authentication info
|
||
const nip46Info = {
|
||
pubkey: userPubkey,
|
||
signer: {
|
||
method: 'nip46',
|
||
remotePubkey: bunkerPointer.pubkey,
|
||
bunkerSigner: signer,
|
||
secret: bunkerPointer.secret,
|
||
relays: bunkerPointer.relays,
|
||
localSecretKey: window.NostrTools.nip19.nsecEncode(localSecretKey)
|
||
}
|
||
};
|
||
|
||
// Set as current auth method
|
||
console.log('[NIP-46] connect success, pubkey:', userPubkey, 'calling _setAuthMethod...');
|
||
this._setAuthMethod('nip46', nip46Info);
|
||
console.log('[NIP-46] _setAuthMethod returned, modal.isVisible:', this.isVisible);
|
||
return;
|
||
|
||
} catch (error) {
|
||
console.error('[NIP-46] connection failed:', error);
|
||
this._showNip46Error(error?.message || String(error));
|
||
}
|
||
}
|
||
|
||
_showNip46Error(message) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Connection Failed';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #dc2626;';
|
||
|
||
const errorMsg = document.createElement('p');
|
||
errorMsg.textContent = `Unable to connect to remote signer: ${message}`;
|
||
errorMsg.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
|
||
|
||
const retryButton = document.createElement('button');
|
||
retryButton.textContent = 'Try Again';
|
||
retryButton.onclick = () => this._showConnectScreen();
|
||
retryButton.style.cssText = this._getButtonStyle();
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back to Options';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(errorMsg);
|
||
this.modalBody.appendChild(retryButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_handleReadonly() {
|
||
// Set read-only mode
|
||
this._setAuthMethod('readonly');
|
||
}
|
||
|
||
_showSeedPhraseScreen() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const description = document.createElement('p');
|
||
description.innerHTML = 'Enter your 12 or 24-word mnemonic seed phrase to derive Nostr accounts, or <span id="generate-new" style="text-decoration: underline; cursor: pointer; color: var(--nl-primary-color);">generate new</span>.';
|
||
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
|
||
|
||
const textarea = document.createElement('textarea');
|
||
// Remove default placeholder text as requested
|
||
textarea.placeholder = '';
|
||
textarea.style.cssText = `
|
||
width: 100%;
|
||
height: 100px;
|
||
padding: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
resize: none;
|
||
font-family: monospace;
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
// Add real-time mnemonic validation
|
||
const formatHint = document.createElement('div');
|
||
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
|
||
|
||
const importButton = document.createElement('button');
|
||
importButton.textContent = 'Import Accounts';
|
||
importButton.disabled = true;
|
||
importButton.onclick = () => {
|
||
if (!importButton.disabled) {
|
||
this._importFromSeedPhrase(textarea.value);
|
||
}
|
||
};
|
||
|
||
// Set initial disabled state
|
||
importButton.style.cssText = `
|
||
display: block;
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: var(--nl-border-width) solid var(--nl-muted-color);
|
||
border-radius: var(--nl-border-radius);
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
cursor: not-allowed;
|
||
transition: all 0.2s;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-muted-color);
|
||
`;
|
||
|
||
textarea.oninput = () => {
|
||
const value = textarea.value.trim();
|
||
if (!value) {
|
||
formatHint.textContent = '';
|
||
// Disable button
|
||
importButton.disabled = true;
|
||
importButton.style.borderColor = 'var(--nl-muted-color)';
|
||
importButton.style.color = 'var(--nl-muted-color)';
|
||
importButton.style.cursor = 'not-allowed';
|
||
return;
|
||
}
|
||
|
||
const isValid = this._validateMnemonic(value);
|
||
if (isValid) {
|
||
const wordCount = value.split(/\s+/).length;
|
||
formatHint.textContent = `✅ Valid ${wordCount}-word mnemonic detected`;
|
||
formatHint.style.color = '#059669';
|
||
// Enable button
|
||
importButton.disabled = false;
|
||
importButton.style.borderColor = 'var(--nl-primary-color)';
|
||
importButton.style.color = 'var(--nl-primary-color)';
|
||
importButton.style.cursor = 'pointer';
|
||
} else {
|
||
formatHint.textContent = '❌ Invalid mnemonic - must be 12 or 24 valid BIP-39 words';
|
||
formatHint.style.color = '#dc2626';
|
||
// Disable button
|
||
importButton.disabled = true;
|
||
importButton.style.borderColor = 'var(--nl-muted-color)';
|
||
importButton.style.color = 'var(--nl-muted-color)';
|
||
importButton.style.cursor = 'not-allowed';
|
||
}
|
||
};
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(textarea);
|
||
this.modalBody.appendChild(formatHint);
|
||
this.modalBody.appendChild(importButton);
|
||
this.modalBody.appendChild(backButton);
|
||
|
||
// Add click handler for the "generate new" link
|
||
const generateLink = document.getElementById('generate-new');
|
||
if (generateLink) {
|
||
generateLink.addEventListener('mouseenter', () => {
|
||
generateLink.style.color = 'var(--nl-accent-color)';
|
||
});
|
||
generateLink.addEventListener('mouseleave', () => {
|
||
generateLink.style.color = 'var(--nl-primary-color)';
|
||
});
|
||
generateLink.addEventListener('click', () => {
|
||
this._generateNewSeedPhrase(textarea, formatHint);
|
||
});
|
||
}
|
||
}
|
||
|
||
_generateNewSeedPhrase(textarea, formatHint) {
|
||
try {
|
||
// Check if NIP-06 is available
|
||
if (!window.NostrTools?.nip06) {
|
||
throw new Error('NIP-06 not available in bundle');
|
||
}
|
||
|
||
// Generate a random 12-word mnemonic using NostrTools
|
||
const mnemonic = window.NostrTools.nip06.generateSeedWords();
|
||
|
||
// Set the generated mnemonic in the textarea
|
||
textarea.value = mnemonic;
|
||
|
||
// Trigger the oninput event to properly validate and enable the button
|
||
if (textarea.oninput) {
|
||
textarea.oninput();
|
||
}
|
||
|
||
// console.log('Generated new seed phrase:', mnemonic.split(/\s+/).length, 'words');
|
||
|
||
} catch (error) {
|
||
console.error('Failed to generate seed phrase:', error);
|
||
formatHint.textContent = '❌ Failed to generate seed phrase - NIP-06 not available';
|
||
formatHint.style.color = '#dc2626';
|
||
}
|
||
}
|
||
|
||
_validateMnemonic(mnemonic) {
|
||
try {
|
||
// Check if NIP-06 is available
|
||
if (!window.NostrTools?.nip06) {
|
||
console.error('NIP-06 not available in bundle');
|
||
return false;
|
||
}
|
||
|
||
const words = mnemonic.trim().split(/\s+/);
|
||
|
||
// Must be 12 or 24 words
|
||
if (words.length !== 12 && words.length !== 24) {
|
||
return false;
|
||
}
|
||
|
||
// Try to validate using NostrTools nip06 - this will throw if invalid
|
||
window.NostrTools.nip06.privateKeyFromSeedWords(mnemonic, '', 0);
|
||
return true;
|
||
} catch (error) {
|
||
// console.log('Mnemonic validation failed:', error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
_importFromSeedPhrase(mnemonic) {
|
||
try {
|
||
const trimmed = mnemonic.trim();
|
||
if (!trimmed) {
|
||
throw new Error('Please enter a mnemonic seed phrase');
|
||
}
|
||
|
||
// Validate the mnemonic
|
||
if (!this._validateMnemonic(trimmed)) {
|
||
throw new Error('Invalid mnemonic. Please enter a valid 12 or 24-word BIP-39 seed phrase');
|
||
}
|
||
|
||
// Generate accounts 0-5 using NIP-06
|
||
const accounts = [];
|
||
for (let i = 0; i < 6; i++) {
|
||
try {
|
||
const privateKey = window.NostrTools.nip06.privateKeyFromSeedWords(trimmed, '', i);
|
||
const publicKey = window.NostrTools.getPublicKey(privateKey);
|
||
const nsec = window.NostrTools.nip19.nsecEncode(privateKey);
|
||
const npub = window.NostrTools.nip19.npubEncode(publicKey);
|
||
|
||
accounts.push({
|
||
index: i,
|
||
privateKey,
|
||
publicKey,
|
||
nsec,
|
||
npub
|
||
});
|
||
} catch (error) {
|
||
console.error(`Failed to derive account ${i}:`, error);
|
||
}
|
||
}
|
||
|
||
if (accounts.length === 0) {
|
||
throw new Error('Failed to derive any accounts from seed phrase');
|
||
}
|
||
|
||
// console.log(`Successfully derived ${accounts.length} accounts from seed phrase`);
|
||
this._showAccountSelection(accounts);
|
||
|
||
} catch (error) {
|
||
console.error('Seed phrase import failed:', error);
|
||
this._showError('Seed phrase import failed: ' + error.message);
|
||
}
|
||
}
|
||
|
||
_showAccountSelection(accounts) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = `Select which account to use (${accounts.length} accounts derived from seed phrase):`;
|
||
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
|
||
|
||
this.modalBody.appendChild(description);
|
||
|
||
// Create table for account selection
|
||
const table = document.createElement('table');
|
||
table.style.cssText = `
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-bottom: 20px;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
font-size: 12px;
|
||
`;
|
||
|
||
// Table header
|
||
const thead = document.createElement('thead');
|
||
thead.innerHTML = `
|
||
<tr style="background: #f3f4f6;">
|
||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
|
||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
|
||
</tr>
|
||
`;
|
||
table.appendChild(thead);
|
||
|
||
// Table body
|
||
const tbody = document.createElement('tbody');
|
||
accounts.forEach(account => {
|
||
const row = document.createElement('tr');
|
||
row.style.cssText = 'border: 1px solid #d1d5db;';
|
||
|
||
const indexCell = document.createElement('td');
|
||
indexCell.textContent = account.index;
|
||
indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;';
|
||
|
||
const actionCell = document.createElement('td');
|
||
actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;';
|
||
|
||
// Show truncated npub in the button
|
||
const truncatedNpub = `${account.npub.slice(0, 12)}...${account.npub.slice(-8)}`;
|
||
|
||
const selectButton = document.createElement('button');
|
||
selectButton.textContent = truncatedNpub;
|
||
selectButton.onclick = () => this._selectAccount(account);
|
||
selectButton.style.cssText = `
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
font-size: 11px;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: 1px solid var(--nl-primary-color);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-family: 'Courier New', monospace;
|
||
text-align: center;
|
||
`;
|
||
selectButton.onmouseover = () => {
|
||
selectButton.style.borderColor = 'var(--nl-accent-color)';
|
||
};
|
||
selectButton.onmouseout = () => {
|
||
selectButton.style.borderColor = 'var(--nl-primary-color)';
|
||
};
|
||
|
||
actionCell.appendChild(selectButton);
|
||
|
||
row.appendChild(indexCell);
|
||
row.appendChild(actionCell);
|
||
tbody.appendChild(row);
|
||
});
|
||
table.appendChild(tbody);
|
||
|
||
this.modalBody.appendChild(table);
|
||
|
||
// Back button
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back to Seed Phrase';
|
||
backButton.onclick = () => this._showSeedPhraseScreen();
|
||
backButton.style.cssText = this._getButtonStyle('secondary');
|
||
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_selectAccount(account) {
|
||
// console.log('Selected account:', account.index, account.npub);
|
||
|
||
// Use the same auth method as local keys, but with seedphrase identifier
|
||
this._setAuthMethod('local', {
|
||
secret: account.nsec,
|
||
pubkey: account.publicKey,
|
||
source: 'seedphrase',
|
||
accountIndex: account.index
|
||
});
|
||
}
|
||
|
||
_showOtpScreen() {
|
||
// Placeholder for OTP functionality
|
||
this._showError('OTP/DM not yet implemented - coming soon!');
|
||
}
|
||
|
||
_getButtonStyle(type = 'primary') {
|
||
const baseStyle = `
|
||
display: block;
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
if (type === 'primary') {
|
||
return baseStyle + `
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
`;
|
||
} else {
|
||
return baseStyle + `
|
||
background: #cccccc;
|
||
color: var(--nl-primary-color);
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Public API
|
||
static init(options) {
|
||
if (Modal.instance) return Modal.instance;
|
||
Modal.instance = new Modal(options);
|
||
return Modal.instance;
|
||
}
|
||
|
||
static getInstance() {
|
||
return Modal.instance;
|
||
}
|
||
}
|
||
|
||
// Initialize global instance
|
||
let modalInstance = null;
|
||
|
||
window.addEventListener('load', () => {
|
||
modalInstance = new Modal();
|
||
});
|
||
|
||
// ======================================
|
||
// NSigner WebUSB Driver
|
||
// ======================================
|
||
|
||
class NSignerWebUSB {
|
||
// Keep WebUSB chooser unfiltered so users can select any USB device.
|
||
// WebUSB requires a non-empty filters array; {} matches all devices.
|
||
static FILTERS = [{}];
|
||
|
||
static async requestAndConnect(options = {}) {
|
||
if (!('usb' in navigator)) {
|
||
throw new Error('WebUSB not available in this browser');
|
||
}
|
||
|
||
const device = await navigator.usb.requestDevice({ filters: NSignerWebUSB.FILTERS });
|
||
const driver = new NSignerWebUSB(device, options);
|
||
await driver.open();
|
||
return driver;
|
||
}
|
||
|
||
static async getPairedDevice(options = {}) {
|
||
if (!('usb' in navigator)) return null;
|
||
|
||
const devices = await navigator.usb.getDevices();
|
||
if (!devices || devices.length === 0) return null;
|
||
|
||
const vendorId = options.vendorId ?? null;
|
||
const productId = options.productId ?? null;
|
||
const serial = options.serialNumber || null;
|
||
|
||
return devices.find((d) => {
|
||
if (vendorId !== null && vendorId !== undefined && d.vendorId !== vendorId) {
|
||
return false;
|
||
}
|
||
|
||
if (productId !== null && productId !== undefined && d.productId !== productId) {
|
||
return false;
|
||
}
|
||
|
||
if (serial && d.serialNumber) return d.serialNumber === serial;
|
||
return true;
|
||
}) || null;
|
||
}
|
||
|
||
static randomSecretHex() {
|
||
const bytes = new Uint8Array(32);
|
||
crypto.getRandomValues(bytes);
|
||
return NSignerWebUSB._hex(bytes);
|
||
}
|
||
|
||
static toPubkeyHex(secretHex) {
|
||
const secret = NSignerWebUSB._hexToBytes(secretHex);
|
||
const nt = window.NostrTools || {};
|
||
|
||
if (nt.schnorr && typeof nt.schnorr.getPublicKey === 'function') {
|
||
const pub = nt.schnorr.getPublicKey(secret);
|
||
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebUSB._hex(pub);
|
||
}
|
||
|
||
if (typeof nt.getPublicKey === 'function') {
|
||
const pub = nt.getPublicKey(secret);
|
||
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebUSB._hex(pub);
|
||
}
|
||
|
||
throw new Error('NostrTools.getPublicKey is unavailable in this bundle');
|
||
}
|
||
|
||
constructor(usbDevice, { callerSecretKey, nostrIndex = 0 } = {}) {
|
||
if (!usbDevice) throw new Error('USB device is required');
|
||
if (!callerSecretKey) throw new Error('callerSecretKey is required');
|
||
|
||
this.device = usbDevice;
|
||
this.callerSecretKey = callerSecretKey;
|
||
this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0;
|
||
|
||
this.iface = null;
|
||
this.epIn = 1;
|
||
this.epOut = 1;
|
||
this._busy = false;
|
||
this._disconnectHandlers = new Set();
|
||
this._rpcCounter = 0;
|
||
this._boundDisconnect = (event) => {
|
||
if (event.device === this.device) {
|
||
for (const cb of this._disconnectHandlers) {
|
||
try { cb(event); } catch (_) {}
|
||
}
|
||
}
|
||
};
|
||
|
||
if (navigator.usb?.addEventListener) {
|
||
navigator.usb.addEventListener('disconnect', this._boundDisconnect);
|
||
}
|
||
}
|
||
|
||
get isOpen() {
|
||
return !!this.device?.opened;
|
||
}
|
||
|
||
get vendorId() {
|
||
return this.device?.vendorId;
|
||
}
|
||
|
||
get productId() {
|
||
return this.device?.productId;
|
||
}
|
||
|
||
get serial() {
|
||
return this.device?.serialNumber || null;
|
||
}
|
||
|
||
onDisconnect(cb) {
|
||
if (typeof cb === 'function') this._disconnectHandlers.add(cb);
|
||
return () => this._disconnectHandlers.delete(cb);
|
||
}
|
||
|
||
async open() {
|
||
if (!this.device.opened) await this.device.open();
|
||
if (this.device.configuration === null) await this.device.selectConfiguration(1);
|
||
|
||
const intf = this.device.configuration.interfaces.find(i =>
|
||
i.alternates.some(a => a.interfaceClass === 0xff)
|
||
);
|
||
|
||
if (!intf) throw new Error('No vendor WebUSB interface found');
|
||
|
||
this.iface = intf.interfaceNumber;
|
||
await this.device.claimInterface(this.iface);
|
||
|
||
const alt = intf.alternates.find(a => a.interfaceClass === 0xff);
|
||
if (alt) await this.device.selectAlternateInterface(this.iface, alt.alternateSetting);
|
||
|
||
await this.device.controlTransferOut({
|
||
requestType: 'class',
|
||
recipient: 'interface',
|
||
request: 0x22,
|
||
value: 1,
|
||
index: this.iface
|
||
});
|
||
}
|
||
|
||
async close() {
|
||
try {
|
||
if (this.device?.opened && this.iface !== null) {
|
||
await this.device.releaseInterface(this.iface);
|
||
}
|
||
} catch (_) {}
|
||
|
||
try {
|
||
if (this.device?.opened) await this.device.close();
|
||
} catch (_) {}
|
||
|
||
this.iface = null;
|
||
|
||
if (navigator.usb?.removeEventListener && this._boundDisconnect) {
|
||
navigator.usb.removeEventListener('disconnect', this._boundDisconnect);
|
||
}
|
||
}
|
||
|
||
async getPublicKey() {
|
||
const params = [{ nostr_index: this.nostrIndex }];
|
||
const resp = await this._rpcCall('nostr_get_public_key', params);
|
||
if (!resp || typeof resp.result !== 'string') {
|
||
throw new Error('Invalid nostr_get_public_key response');
|
||
}
|
||
return resp.result.trim().toLowerCase();
|
||
}
|
||
|
||
async signEvent(unsignedEvent) {
|
||
const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
|
||
const resp = await this._rpcCall('nostr_sign_event', params);
|
||
if (!resp || typeof resp.result !== 'object') {
|
||
throw new Error('Invalid nostr_sign_event response');
|
||
}
|
||
return resp.result;
|
||
}
|
||
|
||
async nip04Encrypt(peerHex, plaintext) {
|
||
const resp = await this._rpcCall('nostr_nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
|
||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_encrypt response');
|
||
return resp.result;
|
||
}
|
||
|
||
async nip04Decrypt(peerHex, ciphertext) {
|
||
const resp = await this._rpcCall('nostr_nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
|
||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_decrypt response');
|
||
return resp.result;
|
||
}
|
||
|
||
async nip44Encrypt(peerHex, plaintext) {
|
||
const resp = await this._rpcCall('nostr_nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
|
||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_encrypt response');
|
||
return resp.result;
|
||
}
|
||
|
||
async nip44Decrypt(peerHex, ciphertext) {
|
||
const resp = await this._rpcCall('nostr_nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
|
||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_decrypt response');
|
||
return resp.result;
|
||
}
|
||
|
||
async _rpcCall(method, params) {
|
||
if (this._busy) {
|
||
throw new Error('n_signer device is busy; wait for previous request');
|
||
}
|
||
|
||
this._busy = true;
|
||
try {
|
||
const id = `nl-${Date.now()}-${++this._rpcCounter}`;
|
||
const auth = await this._buildAuth(id, method, params);
|
||
const req = { jsonrpc: '2.0', id, method, params, auth };
|
||
const resp = await this._sendRpc(req);
|
||
|
||
if (resp?.error) {
|
||
const msg = resp.error?.message || JSON.stringify(resp.error);
|
||
throw new Error(`n_signer ${method} failed: ${msg}`);
|
||
}
|
||
|
||
return resp;
|
||
} finally {
|
||
this._busy = false;
|
||
}
|
||
}
|
||
|
||
async _buildAuth(rpcId, method, params) {
|
||
const callerPriv = NSignerWebUSB._hexToBytes(this.callerSecretKey);
|
||
const callerPubX = NSignerWebUSB.toPubkeyHex(this.callerSecretKey);
|
||
|
||
const createdAt = Math.floor(Date.now() / 1000);
|
||
const paramsJson = JSON.stringify(params ?? null);
|
||
const bodyHash = await NSignerWebUSB._sha256Hex(NSignerWebUSB._utf8(paramsJson));
|
||
|
||
const tags = [
|
||
['nsigner_rpc', String(rpcId)],
|
||
['nsigner_method', String(method)],
|
||
['nsigner_body_hash', bodyHash]
|
||
];
|
||
|
||
const content = 'nostr_login_lite';
|
||
const nt = window.NostrTools || {};
|
||
|
||
// Prefer finalizeEvent() because it is stable across nostr-tools bundle shapes.
|
||
if (typeof nt.finalizeEvent === 'function') {
|
||
const finalized = nt.finalizeEvent({
|
||
kind: 27235,
|
||
created_at: createdAt,
|
||
tags,
|
||
content
|
||
}, callerPriv);
|
||
|
||
return {
|
||
id: String(finalized.id || '').toLowerCase(),
|
||
pubkey: String(finalized.pubkey || callerPubX).toLowerCase(),
|
||
created_at: createdAt,
|
||
kind: 27235,
|
||
tags,
|
||
content,
|
||
sig: String(finalized.sig || '').toLowerCase()
|
||
};
|
||
}
|
||
|
||
// Fallback path for bundles exposing schnorr directly.
|
||
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
|
||
const id = await NSignerWebUSB._sha256Hex(NSignerWebUSB._utf8(ser));
|
||
|
||
if (!nt.schnorr || typeof nt.schnorr.sign !== 'function') {
|
||
throw new Error('NostrTools signer unavailable (need finalizeEvent or schnorr.sign)');
|
||
}
|
||
|
||
const sigBytes = await nt.schnorr.sign(id, callerPriv, new Uint8Array(32));
|
||
const sigHex = typeof sigBytes === 'string' ? sigBytes : NSignerWebUSB._hex(sigBytes);
|
||
|
||
return {
|
||
id,
|
||
pubkey: callerPubX,
|
||
created_at: createdAt,
|
||
kind: 27235,
|
||
tags,
|
||
content,
|
||
sig: sigHex
|
||
};
|
||
}
|
||
|
||
async _sendRpc(reqObj) {
|
||
const reqJson = JSON.stringify(reqObj);
|
||
console.info('NSigner _sendRpc outbound JSON:', reqJson);
|
||
|
||
const body = NSignerWebUSB._utf8(reqJson);
|
||
const frame = new Uint8Array(4 + body.length);
|
||
frame.set(NSignerWebUSB._be32(body.length), 0);
|
||
frame.set(body, 4);
|
||
|
||
console.info('NSigner _sendRpc transferOut:', {
|
||
endpoint: this.epOut,
|
||
payloadBytes: body.length,
|
||
frameBytes: frame.length
|
||
});
|
||
|
||
await this.device.transferOut(this.epOut, frame);
|
||
|
||
const deadline = Date.now() + 30000;
|
||
let ring = new Uint8Array(0);
|
||
|
||
while (Date.now() < deadline) {
|
||
const r = await this.device.transferIn(this.epIn, 512);
|
||
|
||
if (!r || !r.data || r.data.byteLength === 0) continue;
|
||
|
||
const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
|
||
const next = new Uint8Array(ring.length + chunk.length);
|
||
next.set(ring, 0);
|
||
next.set(chunk, ring.length);
|
||
ring = next;
|
||
|
||
while (ring.length >= 4) {
|
||
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
|
||
if (n <= 0 || n > 1_000_000) {
|
||
ring = ring.slice(1);
|
||
continue;
|
||
}
|
||
if (ring.length < 4 + n) break;
|
||
|
||
const payload = ring.slice(4, 4 + n);
|
||
ring = ring.slice(4 + n);
|
||
const txt = new TextDecoder().decode(payload);
|
||
return JSON.parse(txt);
|
||
}
|
||
}
|
||
|
||
throw new Error('Timed out waiting for n_signer response');
|
||
}
|
||
|
||
static _utf8(s) {
|
||
return new TextEncoder().encode(s);
|
||
}
|
||
|
||
static _be32(n) {
|
||
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||
}
|
||
|
||
static _hex(bytes) {
|
||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
static _hexToBytes(hex) {
|
||
const v = String(hex || '').trim().toLowerCase();
|
||
if (!/^[0-9a-f]{64}$/.test(v)) {
|
||
throw new Error('Secret key must be 64 hex chars');
|
||
}
|
||
const out = new Uint8Array(32);
|
||
for (let i = 0; i < 32; i++) out[i] = parseInt(v.slice(i * 2, i * 2 + 2), 16);
|
||
return out;
|
||
}
|
||
|
||
static async _sha256Hex(dataBytes) {
|
||
const h = await crypto.subtle.digest('SHA-256', dataBytes);
|
||
return NSignerWebUSB._hex(new Uint8Array(h));
|
||
}
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.NSignerWebUSB = NSignerWebUSB;
|
||
}
|
||
|
||
|
||
// ======================================
|
||
// NSigner WebSerial Driver (CYD)
|
||
// ======================================
|
||
|
||
class NSignerWebSerial {
|
||
// Keep Web Serial chooser unfiltered so users can select any serial port.
|
||
static FILTERS = [];
|
||
|
||
static async requestAndConnect(options = {}) {
|
||
if (!('serial' in navigator)) {
|
||
throw new Error('Web Serial API not available in this browser (requires Chrome 89+, Edge 89+, or Brave)');
|
||
}
|
||
|
||
const port = NSignerWebSerial.FILTERS.length
|
||
? await navigator.serial.requestPort({ filters: NSignerWebSerial.FILTERS })
|
||
: await navigator.serial.requestPort();
|
||
const driver = new NSignerWebSerial(port, options);
|
||
await driver.open();
|
||
return driver;
|
||
}
|
||
|
||
static async getPairedDevice(options = {}) {
|
||
if (!('serial' in navigator)) return null;
|
||
|
||
const ports = await navigator.serial.getPorts();
|
||
if (!ports || ports.length === 0) return null;
|
||
|
||
const vendorId = options.vendorId ?? null;
|
||
const productId = options.productId ?? null;
|
||
|
||
const match = ports.find(p => {
|
||
const info = p.getInfo();
|
||
if (vendorId !== null && info?.usbVendorId !== vendorId) return false;
|
||
if (productId !== null && info?.usbProductId !== productId) return false;
|
||
return true;
|
||
});
|
||
|
||
return match || null;
|
||
}
|
||
|
||
// Identical helpers to NSignerWebUSB — kept here so the class is self-contained.
|
||
static randomSecretHex() {
|
||
const bytes = new Uint8Array(32);
|
||
crypto.getRandomValues(bytes);
|
||
return NSignerWebSerial._hex(bytes);
|
||
}
|
||
|
||
static toPubkeyHex(secretHex) {
|
||
const secret = NSignerWebSerial._hexToBytes(secretHex);
|
||
const nt = window.NostrTools || {};
|
||
|
||
if (nt.schnorr && typeof nt.schnorr.getPublicKey === 'function') {
|
||
const pub = nt.schnorr.getPublicKey(secret);
|
||
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub);
|
||
}
|
||
|
||
if (typeof nt.getPublicKey === 'function') {
|
||
const pub = nt.getPublicKey(secret);
|
||
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub);
|
||
}
|
||
|
||
throw new Error('NostrTools.getPublicKey is unavailable in this bundle');
|
||
}
|
||
|
||
constructor(port, { callerSecretKey, nostrIndex = 0, openStabilizeMs = 1200, signalStrategies = null } = {}) {
|
||
if (!port) throw new Error('SerialPort is required');
|
||
if (!callerSecretKey) throw new Error('callerSecretKey is required');
|
||
|
||
this._port = port;
|
||
this.callerSecretKey = callerSecretKey;
|
||
this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0;
|
||
|
||
this._reader = null;
|
||
this._readLoopPromise = null;
|
||
this._ring = new Uint8Array(0);
|
||
this._pending = new Map(); // id -> { resolve, reject, timer }
|
||
this._disconnectHandlers = new Set();
|
||
this._rpcCounter = 0;
|
||
this._open = false;
|
||
this._opening = false;
|
||
this._sawDisconnectDuringOpen = false;
|
||
this._openStabilizeMs = Math.max(0, Number(openStabilizeMs) || 1200);
|
||
this._signalStrategies = Array.isArray(signalStrategies) && signalStrategies.length
|
||
? signalStrategies
|
||
: [
|
||
{ dataTerminalReady: false, requestToSend: false, label: 'dtr=0 rts=0' },
|
||
{ dataTerminalReady: true, requestToSend: true, label: 'dtr=1 rts=1' }
|
||
];
|
||
|
||
// Bound port-level disconnect listener.
|
||
this._boundPortDisconnect = () => {
|
||
if (this._opening) this._sawDisconnectDuringOpen = true;
|
||
this._handleDisconnect();
|
||
};
|
||
}
|
||
|
||
get isOpen() {
|
||
return this._open;
|
||
}
|
||
|
||
get vendorId() {
|
||
return this._port?.getInfo()?.usbVendorId ?? null;
|
||
}
|
||
|
||
get productId() {
|
||
return this._port?.getInfo()?.usbProductId ?? null;
|
||
}
|
||
|
||
// Web Serial / CH340 / CP2102 do not expose a serial number.
|
||
get serial() {
|
||
return null;
|
||
}
|
||
|
||
onDisconnect(cb) {
|
||
if (typeof cb === 'function') this._disconnectHandlers.add(cb);
|
||
return () => this._disconnectHandlers.delete(cb);
|
||
}
|
||
|
||
async open() {
|
||
const originalInfo = this._port?.getInfo?.() || {};
|
||
let lastError = null;
|
||
|
||
for (let attempt = 0; attempt < 2; attempt++) {
|
||
this._opening = true;
|
||
this._sawDisconnectDuringOpen = false;
|
||
|
||
try {
|
||
if (attempt > 0) {
|
||
const replacement = await NSignerWebSerial._findReenumeratedPort(originalInfo);
|
||
if (replacement) this._port = replacement;
|
||
}
|
||
|
||
await this._port.open({
|
||
baudRate: 115200,
|
||
dataBits: 8,
|
||
parity: 'none',
|
||
stopBits: 1,
|
||
flowControl: 'none'
|
||
});
|
||
|
||
this._port.addEventListener('disconnect', this._boundPortDisconnect);
|
||
|
||
await this._applySignalStrategies();
|
||
|
||
// Wait for potential auto-reset / re-enumeration after open+signals.
|
||
await new Promise(r => setTimeout(r, this._openStabilizeMs));
|
||
|
||
if (this._sawDisconnectDuringOpen || !this._port?.readable || !this._port?.writable) {
|
||
throw new Error('Serial port dropped during open stabilization');
|
||
}
|
||
|
||
this._open = true;
|
||
this._readLoopPromise = this._readLoop();
|
||
return;
|
||
} catch (err) {
|
||
lastError = err;
|
||
|
||
try { this._port.removeEventListener('disconnect', this._boundPortDisconnect); } catch (_) {}
|
||
try { await this._port.close(); } catch (_) {}
|
||
|
||
// Short wait before retrying in case device just re-enumerated.
|
||
await new Promise(r => setTimeout(r, 400));
|
||
} finally {
|
||
this._opening = false;
|
||
}
|
||
}
|
||
|
||
throw new Error(`Failed to open n_signer serial port: ${lastError?.message || lastError}`);
|
||
}
|
||
|
||
async close() {
|
||
this._open = false;
|
||
|
||
// Cancel the reader — this breaks out of the read loop.
|
||
if (this._reader) {
|
||
try { await this._reader.cancel(); } catch (_) {}
|
||
}
|
||
|
||
// Reject all in-flight RPCs.
|
||
for (const [, { reject, timer }] of this._pending) {
|
||
clearTimeout(timer);
|
||
reject(new Error('Connection closed'));
|
||
}
|
||
this._pending.clear();
|
||
|
||
this._port.removeEventListener('disconnect', this._boundPortDisconnect);
|
||
|
||
try { await this._port.close(); } catch (_) {}
|
||
}
|
||
|
||
// ── Public RPC methods (identical surface to NSignerWebUSB) ────────────────
|
||
|
||
async getPublicKey() {
|
||
const params = [{ nostr_index: this.nostrIndex }];
|
||
const resp = await this._rpcCall('nostr_get_public_key', params);
|
||
if (!resp || typeof resp.result !== 'string') {
|
||
throw new Error('Invalid nostr_get_public_key response');
|
||
}
|
||
return resp.result.trim().toLowerCase();
|
||
}
|
||
|
||
async signEvent(unsignedEvent) {
|
||
const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
|
||
const resp = await this._rpcCall('nostr_sign_event', params);
|
||
if (!resp || typeof resp.result !== 'object') {
|
||
throw new Error('Invalid nostr_sign_event response');
|
||
}
|
||
return resp.result;
|
||
}
|
||
|
||
async nip04Encrypt(peerHex, plaintext) {
|
||
const resp = await this._rpcCall('nostr_nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
|
||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_encrypt response');
|
||
return resp.result;
|
||
}
|
||
|
||
async nip04Decrypt(peerHex, ciphertext) {
|
||
const resp = await this._rpcCall('nostr_nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
|
||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_decrypt response');
|
||
return resp.result;
|
||
}
|
||
|
||
async nip44Encrypt(peerHex, plaintext) {
|
||
const resp = await this._rpcCall('nostr_nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
|
||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_encrypt response');
|
||
return resp.result;
|
||
}
|
||
|
||
async nip44Decrypt(peerHex, ciphertext) {
|
||
const resp = await this._rpcCall('nostr_nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
|
||
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_decrypt response');
|
||
return resp.result;
|
||
}
|
||
|
||
// ── Internal ───────────────────────────────────────────────────────────────
|
||
|
||
async _rpcCall(method, params) {
|
||
const id = `nl-${Date.now()}-${++this._rpcCounter}`;
|
||
const auth = await this._buildAuth(id, method, params);
|
||
const req = { jsonrpc: '2.0', id, method, params, auth };
|
||
|
||
console.info('NSignerWebSerial _rpcCall outbound:', JSON.stringify(req));
|
||
|
||
const resp = await this._sendRpc(req);
|
||
|
||
if (resp?.error) {
|
||
const msg = resp.error?.message || JSON.stringify(resp.error);
|
||
throw new Error(`n_signer ${method} failed: ${msg}`);
|
||
}
|
||
|
||
return resp;
|
||
}
|
||
|
||
_sendRpc(reqObj) {
|
||
return new Promise((resolve, reject) => {
|
||
const id = reqObj.id;
|
||
const timer = setTimeout(() => {
|
||
this._pending.delete(id);
|
||
reject(new Error('Timed out waiting for n_signer response'));
|
||
}, 30000);
|
||
|
||
this._pending.set(id, { resolve, reject, timer });
|
||
|
||
this._writeFrame(reqObj).catch(err => {
|
||
this._pending.delete(id);
|
||
clearTimeout(timer);
|
||
reject(err);
|
||
});
|
||
});
|
||
}
|
||
|
||
async _writeFrame(reqObj) {
|
||
const body = NSignerWebSerial._utf8(JSON.stringify(reqObj));
|
||
const frame = new Uint8Array(4 + body.length);
|
||
frame.set(NSignerWebSerial._be32(body.length), 0);
|
||
frame.set(body, 4);
|
||
|
||
console.info('NSignerWebSerial _writeFrame:', { payloadBytes: body.length, frameBytes: frame.length });
|
||
|
||
// Acquire writer, write, release immediately.
|
||
const w = this._port.writable.getWriter();
|
||
try {
|
||
await w.write(frame);
|
||
} finally {
|
||
w.releaseLock();
|
||
}
|
||
}
|
||
|
||
async _readLoop() {
|
||
let ring = new Uint8Array(0);
|
||
|
||
try {
|
||
this._reader = this._port.readable.getReader();
|
||
|
||
while (true) {
|
||
const { value, done } = await this._reader.read();
|
||
if (done) break;
|
||
if (!value || value.length === 0) continue;
|
||
|
||
// Append chunk to ring buffer.
|
||
const next = new Uint8Array(ring.length + value.length);
|
||
next.set(ring, 0);
|
||
next.set(value, ring.length);
|
||
ring = next;
|
||
|
||
// Parse as many complete frames as possible.
|
||
while (ring.length >= 4) {
|
||
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
|
||
|
||
// Invalid length header — slide one byte (boot-log recovery).
|
||
if (n <= 0 || n > 1_000_000) {
|
||
ring = ring.slice(1);
|
||
continue;
|
||
}
|
||
|
||
// Not enough bytes yet for the full payload.
|
||
if (ring.length < 4 + n) break;
|
||
|
||
const payload = ring.slice(4, 4 + n);
|
||
ring = ring.slice(4 + n);
|
||
|
||
let resp;
|
||
try {
|
||
resp = JSON.parse(new TextDecoder().decode(payload));
|
||
} catch (e) {
|
||
console.warn('NSignerWebSerial: frame parse error:', e);
|
||
continue;
|
||
}
|
||
|
||
console.info('NSignerWebSerial _readLoop inbound:', JSON.stringify(resp));
|
||
|
||
// Resolve the matching pending RPC.
|
||
if (resp && resp.id && this._pending.has(resp.id)) {
|
||
const { resolve, timer } = this._pending.get(resp.id);
|
||
this._pending.delete(resp.id);
|
||
clearTimeout(timer);
|
||
resolve(resp);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (err && err.name !== 'AbortError') {
|
||
console.warn('NSignerWebSerial read loop error:', err);
|
||
}
|
||
} finally {
|
||
try { this._reader.releaseLock(); } catch (_) {}
|
||
this._reader = null;
|
||
this._handleDisconnect();
|
||
}
|
||
}
|
||
|
||
_handleDisconnect() {
|
||
if (!this._open && !this._opening) return; // already closed cleanly
|
||
this._open = false;
|
||
|
||
// Reject all in-flight RPCs.
|
||
for (const [, { reject, timer }] of this._pending) {
|
||
clearTimeout(timer);
|
||
reject(new Error('n_signer device disconnected'));
|
||
}
|
||
this._pending.clear();
|
||
|
||
for (const cb of this._disconnectHandlers) {
|
||
try { cb(); } catch (_) {}
|
||
}
|
||
}
|
||
|
||
async _buildAuth(rpcId, method, params) {
|
||
const callerPriv = NSignerWebSerial._hexToBytes(this.callerSecretKey);
|
||
const callerPubX = NSignerWebSerial.toPubkeyHex(this.callerSecretKey);
|
||
|
||
const createdAt = Math.floor(Date.now() / 1000);
|
||
const paramsJson = JSON.stringify(params ?? null);
|
||
const bodyHash = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(paramsJson));
|
||
|
||
const tags = [
|
||
['nsigner_rpc', String(rpcId)],
|
||
['nsigner_method', String(method)],
|
||
['nsigner_body_hash', bodyHash]
|
||
];
|
||
|
||
const content = 'nostr_login_lite';
|
||
const nt = window.NostrTools || {};
|
||
|
||
// Prefer finalizeEvent() — stable across nostr-tools bundle shapes.
|
||
if (typeof nt.finalizeEvent === 'function') {
|
||
const finalized = nt.finalizeEvent({
|
||
kind: 27235,
|
||
created_at: createdAt,
|
||
tags,
|
||
content
|
||
}, callerPriv);
|
||
|
||
return {
|
||
id: String(finalized.id || '').toLowerCase(),
|
||
pubkey: String(finalized.pubkey || callerPubX).toLowerCase(),
|
||
created_at: createdAt,
|
||
kind: 27235,
|
||
tags,
|
||
content,
|
||
sig: String(finalized.sig || '').toLowerCase()
|
||
};
|
||
}
|
||
|
||
// Fallback: schnorr.sign directly.
|
||
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
|
||
const id = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(ser));
|
||
|
||
if (!nt.schnorr || typeof nt.schnorr.sign !== 'function') {
|
||
throw new Error('NostrTools signer unavailable (need finalizeEvent or schnorr.sign)');
|
||
}
|
||
|
||
const sigBytes = await nt.schnorr.sign(id, callerPriv, new Uint8Array(32));
|
||
const sigHex = typeof sigBytes === 'string' ? sigBytes : NSignerWebSerial._hex(sigBytes);
|
||
|
||
return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
|
||
}
|
||
|
||
async _applySignalStrategies() {
|
||
if (!this._port?.setSignals) return;
|
||
|
||
for (const strategy of this._signalStrategies) {
|
||
try {
|
||
await this._port.setSignals({
|
||
dataTerminalReady: !!strategy.dataTerminalReady,
|
||
requestToSend: !!strategy.requestToSend
|
||
});
|
||
// Small settle gap between strategies.
|
||
await new Promise(r => setTimeout(r, 120));
|
||
} catch (_) {
|
||
// Ignore unsupported setSignals implementations.
|
||
}
|
||
|
||
if (this._sawDisconnectDuringOpen) return;
|
||
}
|
||
}
|
||
|
||
static async _findReenumeratedPort(matchInfo = {}) {
|
||
try {
|
||
if (!navigator.serial?.getPorts) return null;
|
||
const ports = await navigator.serial.getPorts();
|
||
if (!ports?.length) return null;
|
||
|
||
const vid = matchInfo?.usbVendorId;
|
||
const pid = matchInfo?.usbProductId;
|
||
|
||
// Prefer exact VID/PID match when available, but do not require it.
|
||
const exact = ports.find((p) => {
|
||
const i = p.getInfo?.() || {};
|
||
if (vid != null && i.usbVendorId !== vid) return false;
|
||
if (pid != null && i.usbProductId !== pid) return false;
|
||
return true;
|
||
});
|
||
|
||
return exact || ports[0] || null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── Static utilities (mirrors NSignerWebUSB) ───────────────────────────────
|
||
|
||
static _utf8(s) {
|
||
return new TextEncoder().encode(s);
|
||
}
|
||
|
||
static _be32(n) {
|
||
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||
}
|
||
|
||
static _hex(bytes) {
|
||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
static _hexToBytes(hex) {
|
||
const v = String(hex || '').trim().toLowerCase();
|
||
if (!/^[0-9a-f]{64}$/.test(v)) {
|
||
throw new Error('Secret key must be 64 hex chars');
|
||
}
|
||
const out = new Uint8Array(32);
|
||
for (let i = 0; i < 32; i++) out[i] = parseInt(v.slice(i * 2, i * 2 + 2), 16);
|
||
return out;
|
||
}
|
||
|
||
static async _sha256Hex(dataBytes) {
|
||
const h = await crypto.subtle.digest('SHA-256', dataBytes);
|
||
return NSignerWebSerial._hex(new Uint8Array(h));
|
||
}
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.NSignerWebSerial = NSignerWebSerial;
|
||
}
|
||
|
||
|
||
|
||
// ======================================
|
||
// FloatingTab Component (Recovered from git history)
|
||
// ======================================
|
||
|
||
class FloatingTab {
|
||
constructor(modal, options = {}) {
|
||
this.modal = modal;
|
||
this.options = {
|
||
enabled: true,
|
||
hPosition: 1.0, // 0.0 = left, 1.0 = right
|
||
vPosition: 0.5, // 0.0 = top, 1.0 = bottom
|
||
offset: { x: 0, y: 0 },
|
||
appearance: {
|
||
style: 'pill', // 'pill', 'square', 'circle'
|
||
theme: 'auto', // 'auto', 'light', 'dark'
|
||
icon: '',
|
||
text: 'Login',
|
||
iconOnly: false
|
||
},
|
||
behavior: {
|
||
hideWhenAuthenticated: true,
|
||
showUserInfo: true,
|
||
autoSlide: true,
|
||
persistent: false
|
||
},
|
||
getUserInfo: false,
|
||
getUserRelay: [],
|
||
...options
|
||
};
|
||
|
||
this.userProfile = null;
|
||
this.container = null;
|
||
this.isVisible = false;
|
||
|
||
if (this.options.enabled) {
|
||
this._init();
|
||
}
|
||
}
|
||
|
||
_init() {
|
||
console.log('FloatingTab: Initializing with options:', this.options);
|
||
this._createContainer();
|
||
this._setupEventListeners();
|
||
this._updateAppearance();
|
||
this._position();
|
||
this.show();
|
||
}
|
||
|
||
// Get authentication state from authoritative source (Global Storage-Based Function)
|
||
_getAuthState() {
|
||
return window.NOSTR_LOGIN_LITE?.getAuthState?.() || null;
|
||
}
|
||
|
||
|
||
_createContainer() {
|
||
// Remove existing floating tab if any
|
||
const existingTab = document.getElementById('nl-floating-tab');
|
||
if (existingTab) {
|
||
existingTab.remove();
|
||
}
|
||
|
||
this.container = document.createElement('div');
|
||
this.container.id = 'nl-floating-tab';
|
||
this.container.className = 'nl-floating-tab';
|
||
|
||
// Base styles - positioning and behavior
|
||
this.container.style.cssText = `
|
||
position: fixed;
|
||
z-index: 9999;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
padding: 8px 16px;
|
||
min-width: 80px;
|
||
max-width: 200px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
`;
|
||
|
||
document.body.appendChild(this.container);
|
||
}
|
||
|
||
_setupEventListeners() {
|
||
if (!this.container) return;
|
||
|
||
// Click handler
|
||
this.container.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this._handleClick();
|
||
});
|
||
|
||
// Hover effects
|
||
this.container.addEventListener('mouseenter', () => {
|
||
if (this.options.behavior.autoSlide) {
|
||
this._slideIn();
|
||
}
|
||
});
|
||
|
||
this.container.addEventListener('mouseleave', () => {
|
||
if (this.options.behavior.autoSlide) {
|
||
this._slideOut();
|
||
}
|
||
});
|
||
|
||
// Listen for authentication events
|
||
window.addEventListener('nlMethodSelected', (e) => {
|
||
console.log('🔍 FloatingTab: Authentication method selected event received');
|
||
console.log('🔍 FloatingTab: Event detail:', e.detail);
|
||
this._handleAuth(e.detail);
|
||
});
|
||
|
||
window.addEventListener('nlAuthRestored', (e) => {
|
||
console.log('🔍 FloatingTab: ✅ Authentication restored event received');
|
||
console.log('🔍 FloatingTab: Event detail:', e.detail);
|
||
console.log('🔍 FloatingTab: Calling _handleAuth with restored data...');
|
||
this._handleAuth(e.detail);
|
||
});
|
||
|
||
window.addEventListener('nlLogout', () => {
|
||
console.log('🔍 FloatingTab: Logout event received');
|
||
this._handleLogout();
|
||
});
|
||
|
||
// Check for existing authentication state on initialization
|
||
window.addEventListener('load', () => {
|
||
setTimeout(() => {
|
||
this._checkExistingAuth();
|
||
}, 1000); // Wait 1 second for all initialization to complete
|
||
});
|
||
}
|
||
|
||
// Check for existing authentication on page load
|
||
async _checkExistingAuth() {
|
||
console.log('🔍 FloatingTab: === _checkExistingAuth START ===');
|
||
|
||
try {
|
||
const storageKey = 'nostr_login_lite_auth';
|
||
let storedAuth = null;
|
||
|
||
// Try sessionStorage first, then localStorage
|
||
if (sessionStorage.getItem(storageKey)) {
|
||
storedAuth = JSON.parse(sessionStorage.getItem(storageKey));
|
||
console.log('🔍 FloatingTab: Found auth in sessionStorage:', storedAuth.method);
|
||
} else if (localStorage.getItem(storageKey)) {
|
||
storedAuth = JSON.parse(localStorage.getItem(storageKey));
|
||
console.log('🔍 FloatingTab: Found auth in localStorage:', storedAuth.method);
|
||
}
|
||
|
||
if (storedAuth) {
|
||
// Check if stored auth is not expired
|
||
const maxAge = storedAuth.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
|
||
if (Date.now() - storedAuth.timestamp <= maxAge) {
|
||
console.log('🔍 FloatingTab: Found valid stored auth, simulating auth event');
|
||
|
||
// Create auth data object for FloatingTab
|
||
const authData = {
|
||
method: storedAuth.method,
|
||
pubkey: storedAuth.pubkey
|
||
};
|
||
|
||
// For extensions, try to find the extension
|
||
if (storedAuth.method === 'extension') {
|
||
if (window.nostr && window.nostr.constructor?.name !== 'WindowNostr') {
|
||
authData.extension = window.nostr;
|
||
}
|
||
}
|
||
|
||
await this._handleAuth(authData);
|
||
} else {
|
||
console.log('🔍 FloatingTab: Stored auth expired, clearing');
|
||
sessionStorage.removeItem(storageKey);
|
||
localStorage.removeItem(storageKey);
|
||
}
|
||
} else {
|
||
console.log('🔍 FloatingTab: No existing authentication found');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('🔍 FloatingTab: Error checking existing auth:', error);
|
||
}
|
||
|
||
console.log('🔍 FloatingTab: === _checkExistingAuth END ===');
|
||
}
|
||
|
||
_handleClick() {
|
||
console.log('FloatingTab: Clicked');
|
||
|
||
const authState = this._getAuthState();
|
||
if (authState && this.options.behavior.showUserInfo) {
|
||
// Show user menu or profile options
|
||
this._showUserMenu();
|
||
} else {
|
||
// Always open login modal (consistent with login buttons)
|
||
if (this.modal) {
|
||
this.modal.open({ startScreen: 'login' });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check if object is a real extension (same logic as NostrLite._isRealExtension)
|
||
_isRealExtension(obj) {
|
||
if (!obj || typeof obj !== 'object') {
|
||
return false;
|
||
}
|
||
|
||
// Must have required Nostr methods
|
||
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') {
|
||
return false;
|
||
}
|
||
|
||
// Exclude our own library classes
|
||
const constructorName = obj.constructor?.name;
|
||
if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') {
|
||
return false;
|
||
}
|
||
|
||
// Exclude NostrTools library object
|
||
if (obj === window.NostrTools) {
|
||
return false;
|
||
}
|
||
|
||
// Conservative check: Look for common extension characteristics
|
||
const extensionIndicators = [
|
||
'_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope',
|
||
'_requests', '_pubkey', 'name', 'version', 'description'
|
||
];
|
||
|
||
const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop));
|
||
|
||
// Additional check: Extensions often have specific constructor patterns
|
||
const hasExtensionConstructor = constructorName &&
|
||
constructorName !== 'Object' &&
|
||
constructorName !== 'Function';
|
||
|
||
return hasIndicators || hasExtensionConstructor;
|
||
}
|
||
|
||
// Try to login with extension and trigger proper persistence
|
||
async _tryExtensionLogin(extension) {
|
||
try {
|
||
console.log('FloatingTab: Attempting extension login');
|
||
|
||
// Get pubkey from extension
|
||
const pubkey = await extension.getPublicKey();
|
||
console.log('FloatingTab: Extension provided pubkey:', pubkey);
|
||
|
||
// Create extension auth data
|
||
const extensionAuth = {
|
||
method: 'extension',
|
||
pubkey: pubkey,
|
||
extension: extension
|
||
};
|
||
|
||
// **CRITICAL FIX**: Dispatch nlMethodSelected event to trigger persistence
|
||
console.log('FloatingTab: Dispatching nlMethodSelected for persistence');
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new CustomEvent('nlMethodSelected', {
|
||
detail: extensionAuth
|
||
}));
|
||
}
|
||
|
||
// Also call our local _handleAuth for UI updates
|
||
await this._handleAuth(extensionAuth);
|
||
|
||
} catch (error) {
|
||
console.error('FloatingTab: Extension login failed:', error);
|
||
// Fall back to opening modal
|
||
if (this.modal) {
|
||
this.modal.open({ startScreen: 'login' });
|
||
}
|
||
}
|
||
}
|
||
|
||
async _handleAuth(authData) {
|
||
console.log('🔍 FloatingTab: === _handleAuth START ===');
|
||
console.log('🔍 FloatingTab: authData received:', authData);
|
||
|
||
// Wait a brief moment for WindowNostr to process the authentication
|
||
setTimeout(async () => {
|
||
console.log('🔍 FloatingTab: Checking authentication state from authoritative source...');
|
||
|
||
const authState = this._getAuthState();
|
||
const isAuthenticated = !!authState;
|
||
|
||
console.log('🔍 FloatingTab: Authoritative auth state:', authState);
|
||
console.log('🔍 FloatingTab: Is authenticated:', isAuthenticated);
|
||
|
||
if (isAuthenticated) {
|
||
console.log('🔍 FloatingTab: ✅ Authentication verified from authoritative source');
|
||
} else {
|
||
console.error('🔍 FloatingTab: ❌ Authentication not found in authoritative source');
|
||
}
|
||
|
||
// Fetch user profile if enabled and we have a pubkey
|
||
if (this.options.getUserInfo && authData.pubkey) {
|
||
console.log('🔍 FloatingTab: getUserInfo enabled, fetching profile for:', authData.pubkey);
|
||
try {
|
||
const profile = await this._fetchUserProfile(authData.pubkey);
|
||
this.userProfile = profile;
|
||
console.log('🔍 FloatingTab: User profile fetched:', profile);
|
||
} catch (error) {
|
||
console.warn('🔍 FloatingTab: Failed to fetch user profile:', error);
|
||
this.userProfile = null;
|
||
}
|
||
} else {
|
||
console.log('🔍 FloatingTab: getUserInfo disabled or no pubkey, skipping profile fetch');
|
||
}
|
||
|
||
this._updateAppearance(); // Update UI based on authoritative state
|
||
|
||
console.log('🔍 FloatingTab: hideWhenAuthenticated option:', this.options.behavior.hideWhenAuthenticated);
|
||
|
||
if (this.options.behavior.hideWhenAuthenticated && isAuthenticated) {
|
||
console.log('🔍 FloatingTab: Hiding tab (hideWhenAuthenticated=true and authenticated)');
|
||
this.hide();
|
||
} else {
|
||
console.log('🔍 FloatingTab: Keeping tab visible');
|
||
}
|
||
|
||
}, 500); // Wait 500ms for WindowNostr to complete authentication processing
|
||
|
||
console.log('🔍 FloatingTab: === _handleAuth END ===');
|
||
}
|
||
|
||
_handleLogout() {
|
||
console.log('FloatingTab: Handling logout');
|
||
this.userProfile = null;
|
||
|
||
if (this.options.behavior.hideWhenAuthenticated) {
|
||
this.show();
|
||
}
|
||
|
||
this._updateAppearance();
|
||
}
|
||
|
||
_showUserMenu() {
|
||
// Simple user menu - could be expanded
|
||
const menu = document.createElement('div');
|
||
menu.style.cssText = `
|
||
position: fixed;
|
||
background: var(--nl-secondary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
padding: 12px;
|
||
z-index: 10000;
|
||
font-family: var(--nl-font-family);
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
`;
|
||
|
||
// Position near the floating tab
|
||
const tabRect = this.container.getBoundingClientRect();
|
||
if (this.options.hPosition > 0.5) {
|
||
// Tab is on right side, show menu to the left
|
||
menu.style.right = (window.innerWidth - tabRect.left) + 'px';
|
||
} else {
|
||
// Tab is on left side, show menu to the right
|
||
menu.style.left = tabRect.right + 'px';
|
||
}
|
||
menu.style.top = tabRect.top + 'px';
|
||
|
||
// Menu content - use _getAuthState() as single source of truth
|
||
const authState = this._getAuthState();
|
||
let userDisplay;
|
||
|
||
if (authState?.pubkey) {
|
||
// Use profile name if available, otherwise pubkey
|
||
if (this.userProfile?.name || this.userProfile?.display_name) {
|
||
const userName = this.userProfile.name || this.userProfile.display_name;
|
||
userDisplay = userName.length > 16 ? userName.slice(0, 16) + '...' : userName;
|
||
} else {
|
||
userDisplay = authState.pubkey.slice(0, 8) + '...' + authState.pubkey.slice(-4);
|
||
}
|
||
} else {
|
||
userDisplay = 'Authenticated';
|
||
}
|
||
|
||
menu.innerHTML = `
|
||
<div style="margin-bottom: 8px; font-weight: bold; color: var(--nl-primary-color);">${userDisplay}</div>
|
||
<button onclick="window.NOSTR_LOGIN_LITE.logout(); this.parentElement.remove();"
|
||
style="background: var(--nl-secondary-color); color: var(--nl-primary-color);
|
||
border: 1px solid var(--nl-primary-color); border-radius: 4px;
|
||
padding: 6px 12px; cursor: pointer; width: 100%;">
|
||
Logout
|
||
</button>
|
||
`;
|
||
|
||
document.body.appendChild(menu);
|
||
|
||
// Auto-remove menu after delay or on outside click
|
||
const removeMenu = () => menu.remove();
|
||
setTimeout(removeMenu, 5000);
|
||
|
||
document.addEventListener('click', function onOutsideClick(e) {
|
||
if (!menu.contains(e.target) && e.target !== this.container) {
|
||
removeMenu();
|
||
document.removeEventListener('click', onOutsideClick);
|
||
}
|
||
});
|
||
}
|
||
|
||
_updateAppearance() {
|
||
if (!this.container) return;
|
||
|
||
// Query authoritative source for all state information
|
||
const authState = this._getAuthState();
|
||
const isAuthenticated = authState !== null;
|
||
|
||
// Update content
|
||
if (isAuthenticated && this.options.behavior.showUserInfo) {
|
||
let display;
|
||
|
||
// Use profile name if available, otherwise fall back to pubkey
|
||
if (this.userProfile?.name || this.userProfile?.display_name) {
|
||
const userName = this.userProfile.name || this.userProfile.display_name;
|
||
display = this.options.appearance.iconOnly
|
||
? userName.slice(0, 8)
|
||
: userName;
|
||
} else if (authState?.pubkey) {
|
||
// Fallback to pubkey display
|
||
display = this.options.appearance.iconOnly
|
||
? authState.pubkey.slice(0, 6)
|
||
: authState.pubkey.slice(0, 6) + '...';
|
||
} else {
|
||
display = this.options.appearance.iconOnly ? 'User' : 'Authenticated';
|
||
}
|
||
|
||
this.container.textContent = display;
|
||
this.container.className = 'nl-floating-tab nl-floating-tab--logged-in';
|
||
} else {
|
||
const display = this.options.appearance.iconOnly ?
|
||
this.options.appearance.icon :
|
||
(this.options.appearance.icon ? this.options.appearance.icon + ' ' + this.options.appearance.text : this.options.appearance.text);
|
||
|
||
this.container.textContent = display;
|
||
this.container.className = 'nl-floating-tab nl-floating-tab--logged-out';
|
||
}
|
||
|
||
// Apply appearance styles based on current state
|
||
this._applyThemeStyles();
|
||
}
|
||
|
||
_applyThemeStyles() {
|
||
if (!this.container) return;
|
||
|
||
// The CSS classes will handle the theming through CSS custom properties
|
||
// Additional style customizations can be added here if needed
|
||
|
||
// Apply style variant
|
||
if (this.options.appearance.style === 'circle') {
|
||
this.container.style.borderRadius = '50%';
|
||
this.container.style.width = '48px';
|
||
this.container.style.height = '48px';
|
||
this.container.style.minWidth = '48px';
|
||
this.container.style.padding = '0';
|
||
} else if (this.options.appearance.style === 'square') {
|
||
this.container.style.borderRadius = '4px';
|
||
} else {
|
||
// pill style (default)
|
||
this.container.style.borderRadius = 'var(--nl-border-radius)';
|
||
}
|
||
}
|
||
|
||
async _fetchUserProfile(pubkey) {
|
||
if (!this.options.getUserInfo) {
|
||
console.log('FloatingTab: getUserInfo disabled, skipping profile fetch');
|
||
return null;
|
||
}
|
||
|
||
// Determine which relays to use
|
||
const relays = this.options.getUserRelay.length > 0
|
||
? this.options.getUserRelay
|
||
: ['wss://relay.damus.io', 'wss://nos.lol'];
|
||
|
||
console.log('FloatingTab: Fetching profile from relays:', relays);
|
||
|
||
try {
|
||
// Create a SimplePool instance for querying
|
||
const pool = new window.NostrTools.SimplePool();
|
||
|
||
// Query for kind 0 (user metadata) events
|
||
const events = await pool.querySync(relays, {
|
||
kinds: [0],
|
||
authors: [pubkey],
|
||
limit: 1
|
||
}, { timeout: 5000 });
|
||
|
||
console.log('FloatingTab: Profile query returned', events.length, 'events');
|
||
|
||
if (events.length === 0) {
|
||
console.log('FloatingTab: No profile events found');
|
||
return null;
|
||
}
|
||
|
||
// Get the most recent event
|
||
const latestEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
|
||
|
||
try {
|
||
const profile = JSON.parse(latestEvent.content);
|
||
console.log('FloatingTab: Parsed profile:', profile);
|
||
|
||
// Find the best name from any key containing "name" (case-insensitive)
|
||
let bestName = null;
|
||
const nameKeys = Object.keys(profile).filter(key =>
|
||
key.toLowerCase().includes('name') &&
|
||
typeof profile[key] === 'string' &&
|
||
profile[key].trim().length > 0
|
||
);
|
||
|
||
if (nameKeys.length > 0) {
|
||
// Find the shortest name value
|
||
bestName = nameKeys
|
||
.map(key => profile[key].trim())
|
||
.reduce((shortest, current) =>
|
||
current.length < shortest.length ? current : shortest
|
||
);
|
||
console.log('FloatingTab: Found name keys:', nameKeys, 'selected:', bestName);
|
||
}
|
||
|
||
// Return relevant profile fields with the best name
|
||
return {
|
||
name: bestName,
|
||
display_name: profile.display_name || null,
|
||
about: profile.about || null,
|
||
picture: profile.picture || null,
|
||
nip05: profile.nip05 || null
|
||
};
|
||
} catch (parseError) {
|
||
console.warn('FloatingTab: Failed to parse profile JSON:', parseError);
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
console.error('FloatingTab: Profile fetch error:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
_position() {
|
||
if (!this.container) return;
|
||
|
||
const padding = 16; // Distance from screen edge
|
||
|
||
// Calculate position based on percentage
|
||
const x = this.options.hPosition * (window.innerWidth - this.container.offsetWidth - padding * 2) + padding + this.options.offset.x;
|
||
const y = this.options.vPosition * (window.innerHeight - this.container.offsetHeight - padding * 2) + padding + this.options.offset.y;
|
||
|
||
this.container.style.left = x + 'px';
|
||
this.container.style.top = y + 'px';
|
||
|
||
console.log('FloatingTab: Positioned at (' + x + ', ' + y + ')');
|
||
}
|
||
|
||
_slideIn() {
|
||
if (!this.container || !this.options.behavior.autoSlide) return;
|
||
|
||
// Slide towards center slightly
|
||
const currentTransform = this.container.style.transform || '';
|
||
if (this.options.hPosition > 0.5) {
|
||
this.container.style.transform = currentTransform + ' translateX(-8px)';
|
||
} else {
|
||
this.container.style.transform = currentTransform + ' translateX(8px)';
|
||
}
|
||
}
|
||
|
||
_slideOut() {
|
||
if (!this.container || !this.options.behavior.autoSlide) return;
|
||
|
||
// Reset position
|
||
this.container.style.transform = '';
|
||
}
|
||
|
||
show() {
|
||
if (!this.container) return;
|
||
this.container.style.display = 'flex';
|
||
this.isVisible = true;
|
||
console.log('FloatingTab: Shown');
|
||
}
|
||
|
||
hide() {
|
||
if (!this.container) return;
|
||
this.container.style.display = 'none';
|
||
this.isVisible = false;
|
||
console.log('FloatingTab: Hidden');
|
||
}
|
||
|
||
destroy() {
|
||
if (this.container) {
|
||
this.container.remove();
|
||
this.container = null;
|
||
}
|
||
this.isVisible = false;
|
||
console.log('FloatingTab: Destroyed');
|
||
}
|
||
|
||
// Update options and re-apply
|
||
updateOptions(newOptions) {
|
||
this.options = { ...this.options, ...newOptions };
|
||
if (this.container) {
|
||
this._updateAppearance();
|
||
this._position();
|
||
}
|
||
}
|
||
|
||
// Get current state
|
||
getState() {
|
||
const authState = this._getAuthState();
|
||
return {
|
||
isVisible: this.isVisible,
|
||
isAuthenticated: !!authState,
|
||
userInfo: authState,
|
||
options: this.options
|
||
};
|
||
}
|
||
}
|
||
|
||
// ======================================
|
||
// Main NOSTR_LOGIN_LITE Library
|
||
// ======================================
|
||
|
||
// Extension Bridge for managing browser extensions
|
||
class ExtensionBridge {
|
||
constructor() {
|
||
this.extensions = new Map();
|
||
this.primaryExtension = null;
|
||
this._detectExtensions();
|
||
}
|
||
|
||
_detectExtensions() {
|
||
// Common extension locations
|
||
const locations = [
|
||
{ path: 'window.nostr', name: 'Generic' },
|
||
{ path: 'window.alby?.nostr', name: 'Alby' },
|
||
{ path: 'window.nos2x?.nostr', name: 'nos2x' },
|
||
{ path: 'window.flamingo?.nostr', name: 'Flamingo' },
|
||
{ path: 'window.getAlby?.nostr', name: 'Alby Legacy' },
|
||
{ path: 'window.mutiny?.nostr', name: 'Mutiny' }
|
||
];
|
||
|
||
for (const location of locations) {
|
||
try {
|
||
const obj = eval(location.path);
|
||
if (obj && typeof obj.getPublicKey === 'function') {
|
||
this.extensions.set(location.name, {
|
||
name: location.name,
|
||
extension: obj,
|
||
constructor: obj.constructor?.name || 'Unknown'
|
||
});
|
||
|
||
if (!this.primaryExtension) {
|
||
this.primaryExtension = this.extensions.get(location.name);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Extension not available
|
||
}
|
||
}
|
||
}
|
||
|
||
getAllExtensions() {
|
||
return Array.from(this.extensions.values());
|
||
}
|
||
|
||
getExtensionCount() {
|
||
return this.extensions.size;
|
||
}
|
||
}
|
||
|
||
// Main NostrLite class
|
||
class NostrLite {
|
||
constructor() {
|
||
this.options = {};
|
||
this.extensionBridge = new ExtensionBridge();
|
||
this.initialized = false;
|
||
this.currentTheme = 'default';
|
||
this.modal = null;
|
||
this.floatingTab = null;
|
||
}
|
||
|
||
async init(options = {}) {
|
||
// console.log('NOSTR_LOGIN_LITE: Initializing with options:', options);
|
||
|
||
this.options = {
|
||
theme: 'default',
|
||
persistence: true, // Enable persistent authentication by default
|
||
isolateSession: false, // Use localStorage by default for cross-window persistence
|
||
methods: {
|
||
extension: true,
|
||
local: true,
|
||
seedphrase: false,
|
||
readonly: true,
|
||
connect: false,
|
||
nsigner: true,
|
||
otp: false
|
||
},
|
||
floatingTab: {
|
||
enabled: false,
|
||
hPosition: 1.0,
|
||
vPosition: 0.5,
|
||
offset: { x: 0, y: 0 },
|
||
appearance: {
|
||
style: 'pill',
|
||
theme: 'auto',
|
||
icon: '',
|
||
text: 'Login',
|
||
iconOnly: false
|
||
},
|
||
behavior: {
|
||
hideWhenAuthenticated: true,
|
||
showUserInfo: true,
|
||
autoSlide: true,
|
||
persistent: false
|
||
},
|
||
getUserInfo: false,
|
||
getUserRelay: []
|
||
},
|
||
...options
|
||
};
|
||
|
||
// Apply the selected theme (CSS-only)
|
||
this.switchTheme(this.options.theme);
|
||
|
||
// Always set up window.nostr facade to handle multiple extensions properly
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Setting up facade before other initialization...');
|
||
await this._setupWindowNostrFacade();
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Facade setup complete, continuing initialization...');
|
||
|
||
// Create modal during init (matching original git architecture)
|
||
this.modal = new Modal(this.options);
|
||
// console.log('NOSTR_LOGIN_LITE: Modal created during init');
|
||
|
||
// Initialize floating tab if enabled
|
||
if (this.options.floatingTab.enabled) {
|
||
this.floatingTab = new FloatingTab(this.modal, this.options.floatingTab);
|
||
// console.log('NOSTR_LOGIN_LITE: Floating tab initialized');
|
||
}
|
||
|
||
// Attempt to restore authentication state if persistence is enabled (AFTER facade is ready)
|
||
if (this.options.persistence) {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Persistence enabled, attempting auth restoration...');
|
||
await this._attemptAuthRestore();
|
||
} else {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Persistence disabled in options');
|
||
}
|
||
|
||
this.initialized = true;
|
||
// console.log('NOSTR_LOGIN_LITE: Initialization complete');
|
||
|
||
return this;
|
||
}
|
||
|
||
async _setupWindowNostrFacade() {
|
||
if (typeof window !== 'undefined') {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: === EXTENSION-FIRST FACADE SETUP ===');
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Current window.nostr:', window.nostr);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Constructor:', window.nostr?.constructor?.name);
|
||
|
||
// EXTENSION-FIRST ARCHITECTURE: Never interfere with real extensions
|
||
if (this._isRealExtension(window.nostr)) {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: ✅ REAL EXTENSION DETECTED - WILL NOT INSTALL FACADE');
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Extension constructor:', window.nostr.constructor?.name);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Extensions will handle window.nostr directly');
|
||
|
||
// Store reference for persistence verification
|
||
this.detectedExtension = window.nostr;
|
||
this.hasExtension = true;
|
||
this.facadeInstalled = false; // We deliberately don't install facade for extensions
|
||
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Extension mode - no facade interference');
|
||
return; // Don't install facade at all for extensions
|
||
}
|
||
|
||
// NO EXTENSION: Install facade for local/NIP-46/readonly methods
|
||
console.log('🔍 NOSTR_LOGIN_LITE: ❌ No real extension detected');
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Installing facade for non-extension authentication');
|
||
|
||
this.hasExtension = false;
|
||
this._installFacade(window.nostr); // Install facade with any existing nostr object
|
||
|
||
console.log('🔍 NOSTR_LOGIN_LITE: ✅ Facade installed for local/NIP-46/readonly methods');
|
||
|
||
// CRITICAL FIX: Immediately attempt to restore auth state after facade installation
|
||
if (this.facadeInstalled && window.nostr?.restoreAuthState) {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: 🔄 IMMEDIATELY attempting auth restoration after facade installation');
|
||
try {
|
||
const restoredAuth = await window.nostr.restoreAuthState();
|
||
if (restoredAuth) {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: ✅ Auth state restored immediately during facade setup!');
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Method:', restoredAuth.method);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Pubkey:', restoredAuth.pubkey);
|
||
|
||
// Update facade's authState immediately
|
||
window.nostr.authState = restoredAuth;
|
||
} else {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: ❌ No auth state to restore during facade setup');
|
||
}
|
||
} catch (error) {
|
||
console.error('🔍 NOSTR_LOGIN_LITE: ❌ Error restoring auth during facade setup:', error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
_installFacade(existingNostr = null, forceInstall = false) {
|
||
if (typeof window !== 'undefined' && (!this.facadeInstalled || forceInstall)) {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: === _installFacade CALLED ===');
|
||
console.log('🔍 NOSTR_LOGIN_LITE: existingNostr parameter:', existingNostr);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: existingNostr constructor:', existingNostr?.constructor?.name);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr before installation:', window.nostr);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr constructor before:', window.nostr?.constructor?.name);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: forceInstall flag:', forceInstall);
|
||
|
||
const facade = new WindowNostr(this, existingNostr, { isolateSession: this.options.isolateSession });
|
||
window.nostr = facade;
|
||
this.facadeInstalled = true;
|
||
|
||
console.log('🔍 NOSTR_LOGIN_LITE: === FACADE INSTALLED FOR PERSISTENCE ===');
|
||
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr after installation:', window.nostr);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr constructor after:', window.nostr.constructor?.name);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: facade.existingNostr:', window.nostr.existingNostr);
|
||
} else if (typeof window !== 'undefined') {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: _installFacade skipped - facadeInstalled:', this.facadeInstalled, 'forceInstall:', forceInstall);
|
||
}
|
||
}
|
||
|
||
// Conservative method to identify real browser extensions
|
||
_isRealExtension(obj) {
|
||
console.log('NOSTR_LOGIN_LITE: === _isRealExtension (Conservative) ===');
|
||
console.log('NOSTR_LOGIN_LITE: obj:', obj);
|
||
console.log('NOSTR_LOGIN_LITE: typeof obj:', typeof obj);
|
||
|
||
if (!obj || typeof obj !== 'object') {
|
||
console.log('NOSTR_LOGIN_LITE: ✗ Not an object');
|
||
return false;
|
||
}
|
||
|
||
// Must have required Nostr methods
|
||
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') {
|
||
console.log('NOSTR_LOGIN_LITE: ✗ Missing required NIP-07 methods');
|
||
return false;
|
||
}
|
||
|
||
// Exclude our own library classes
|
||
const constructorName = obj.constructor?.name;
|
||
console.log('NOSTR_LOGIN_LITE: Constructor name:', constructorName);
|
||
|
||
if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') {
|
||
console.log('NOSTR_LOGIN_LITE: ✗ Is our library class - NOT an extension');
|
||
return false;
|
||
}
|
||
|
||
// Exclude NostrTools library object
|
||
if (obj === window.NostrTools) {
|
||
console.log('NOSTR_LOGIN_LITE: ✗ Is NostrTools object - NOT an extension');
|
||
return false;
|
||
}
|
||
|
||
// Conservative check: Look for common extension characteristics
|
||
// Real extensions usually have some of these internal properties
|
||
const extensionIndicators = [
|
||
'_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope',
|
||
'_requests', '_pubkey', 'name', 'version', 'description'
|
||
];
|
||
|
||
const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop));
|
||
|
||
// Additional check: Extensions often have specific constructor patterns
|
||
const hasExtensionConstructor = constructorName &&
|
||
constructorName !== 'Object' &&
|
||
constructorName !== 'Function';
|
||
|
||
const isExtension = hasIndicators || hasExtensionConstructor;
|
||
|
||
console.log('NOSTR_LOGIN_LITE: Extension indicators found:', hasIndicators);
|
||
console.log('NOSTR_LOGIN_LITE: Has extension constructor:', hasExtensionConstructor);
|
||
console.log('NOSTR_LOGIN_LITE: Final result for', constructorName, ':', isExtension);
|
||
|
||
return isExtension;
|
||
}
|
||
|
||
launch(startScreen = 'login') {
|
||
// console.log('NOSTR_LOGIN_LITE: Launching with screen:', startScreen);
|
||
|
||
if (this.modal) {
|
||
this.modal.open({ startScreen });
|
||
} else {
|
||
console.error('NOSTR_LOGIN_LITE: Modal not initialized - call init() first');
|
||
}
|
||
}
|
||
|
||
// Attempt to restore authentication state
|
||
async _attemptAuthRestore() {
|
||
try {
|
||
|
||
|
||
if (this.hasExtension) {
|
||
// EXTENSION MODE: Use custom extension persistence logic
|
||
|
||
const restoredAuth = await this._attemptExtensionRestore();
|
||
|
||
if (restoredAuth) {
|
||
return restoredAuth;
|
||
} else {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: ❌ Extension auth could not be restored');
|
||
return null;
|
||
}
|
||
} else if (this.facadeInstalled && window.nostr?.restoreAuthState) {
|
||
// NON-EXTENSION MODE: Use facade persistence logic
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Non-extension mode - using facade restore');
|
||
const restoredAuth = await window.nostr.restoreAuthState();
|
||
|
||
if (restoredAuth) {
|
||
|
||
|
||
// CRITICAL FIX: Activate facade resilience system for non-extension methods
|
||
// Extensions like nos2x can override our facade after page refresh
|
||
if (restoredAuth.method === 'local' || restoredAuth.method === 'nip46' || restoredAuth.method === 'nsigner') {
|
||
this._activateResilienceProtection(restoredAuth.method);
|
||
}
|
||
|
||
// Handle NIP-46 reconnection requirement
|
||
if (restoredAuth.requiresReconnection) {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: NIP-46 connection requires user reconnection');
|
||
this._showReconnectionPrompt(restoredAuth);
|
||
}
|
||
|
||
return restoredAuth;
|
||
} else {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: ❌ Facade auth could not be restored');
|
||
return null;
|
||
}
|
||
} else {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: ❌ No restoration method available');
|
||
console.log('🔍 NOSTR_LOGIN_LITE: hasExtension:', this.hasExtension);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: facadeInstalled:', this.facadeInstalled);
|
||
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr.restoreAuthState:', typeof window.nostr?.restoreAuthState);
|
||
return null;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('🔍 NOSTR_LOGIN_LITE: Auth restoration failed with error:', error);
|
||
console.error('🔍 NOSTR_LOGIN_LITE: Error stack:', error.stack);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Activate facade resilience protection against extension overrides
|
||
_activateResilienceProtection(method) {
|
||
console.log('🛡️ NOSTR_LOGIN_LITE: === ACTIVATING RESILIENCE PROTECTION ===');
|
||
console.log('🛡️ NOSTR_LOGIN_LITE: Protecting facade for method:', method);
|
||
|
||
// Store the current extension if any (for potential restoration later)
|
||
const preservedExtension = this.preservedExtension ||
|
||
((window.nostr?.constructor?.name !== 'WindowNostr') ? window.nostr : null);
|
||
|
||
// DELAYED FACADE RESILIENCE - Reinstall after extension override attempts
|
||
const forceReinstallFacade = () => {
|
||
console.log('🛡️ NOSTR_LOGIN_LITE: RESILIENCE CHECK - Current window.nostr after delay:', window.nostr?.constructor?.name);
|
||
|
||
// If facade was overridden by extension, reinstall it
|
||
if (window.nostr?.constructor?.name !== 'WindowNostr') {
|
||
console.log('🛡️ NOSTR_LOGIN_LITE: FACADE OVERRIDDEN! Force-reinstalling WindowNostr facade for user choice:', method);
|
||
this._installFacade(preservedExtension, true);
|
||
console.log('🛡️ NOSTR_LOGIN_LITE: Resilient facade force-reinstall complete, window.nostr:', window.nostr?.constructor?.name);
|
||
|
||
// Schedule another check in case of persistent extension override
|
||
setTimeout(() => {
|
||
if (window.nostr?.constructor?.name !== 'WindowNostr') {
|
||
console.log('🛡️ NOSTR_LOGIN_LITE: PERSISTENT OVERRIDE! Final facade force-reinstall for method:', method);
|
||
this._installFacade(preservedExtension, true);
|
||
}
|
||
}, 1000);
|
||
} else {
|
||
console.log('🛡️ NOSTR_LOGIN_LITE: Facade persistence verified - no override detected');
|
||
}
|
||
};
|
||
|
||
// Schedule resilience checks at multiple intervals (same as Modal)
|
||
setTimeout(forceReinstallFacade, 100); // Quick check
|
||
setTimeout(forceReinstallFacade, 500); // Main check
|
||
setTimeout(forceReinstallFacade, 1500); // Final check
|
||
|
||
console.log('🛡️ NOSTR_LOGIN_LITE: Resilience protection scheduled for method:', method);
|
||
}
|
||
|
||
// Extension-specific authentication restoration
|
||
async _attemptExtensionRestore() {
|
||
try {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: === _attemptExtensionRestore START ===');
|
||
|
||
// Use a simple AuthManager instance for extension persistence
|
||
const authManager = new AuthManager({ isolateSession: this.options?.isolateSession });
|
||
const storedAuth = await authManager.restoreAuthState();
|
||
|
||
if (!storedAuth || storedAuth.method !== 'extension') {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: No extension auth state stored');
|
||
return null;
|
||
}
|
||
|
||
// Verify the extension is still available and working
|
||
if (!window.nostr || !this._isRealExtension(window.nostr)) {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Extension no longer available');
|
||
authManager.clearAuthState(); // Clear invalid state
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
// Test that the extension still works with the same pubkey
|
||
const currentPubkey = await window.nostr.getPublicKey();
|
||
if (currentPubkey !== storedAuth.pubkey) {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Extension pubkey changed, clearing state');
|
||
authManager.clearAuthState();
|
||
return null;
|
||
}
|
||
|
||
console.log('🔍 NOSTR_LOGIN_LITE: ✅ Extension auth verification successful');
|
||
|
||
// Create extension auth data for UI restoration
|
||
const extensionAuth = {
|
||
method: 'extension',
|
||
pubkey: storedAuth.pubkey,
|
||
extension: window.nostr
|
||
};
|
||
|
||
// Dispatch restoration event so UI can update
|
||
if (typeof window !== 'undefined') {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Dispatching nlAuthRestored event for extension');
|
||
window.dispatchEvent(new CustomEvent('nlAuthRestored', {
|
||
detail: extensionAuth
|
||
}));
|
||
}
|
||
|
||
return extensionAuth;
|
||
|
||
} catch (error) {
|
||
console.log('🔍 NOSTR_LOGIN_LITE: Extension verification failed:', error);
|
||
authManager.clearAuthState(); // Clear invalid state
|
||
return null;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('🔍 NOSTR_LOGIN_LITE: Extension restore failed:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Show prompt for NIP-46 reconnection
|
||
_showReconnectionPrompt(authData) {
|
||
|
||
|
||
// Dispatch event that UI can listen to
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new CustomEvent('nlReconnectionRequired', {
|
||
detail: {
|
||
method: authData.method,
|
||
pubkey: authData.pubkey,
|
||
connectionData: authData.connectionData,
|
||
message: authData.message || (authData.method === 'nsigner'
|
||
? 'Your n_signer device is not connected. Plug it in and reconnect to continue.'
|
||
: 'Your NIP-46 session has expired. Please reconnect to continue.')
|
||
}
|
||
}));
|
||
}
|
||
}
|
||
|
||
logout() {
|
||
console.log('NOSTR_LOGIN_LITE: Logout called');
|
||
|
||
// Clear legacy stored data
|
||
if (typeof localStorage !== 'undefined') {
|
||
localStorage.removeItem('nl_current');
|
||
}
|
||
|
||
// Clear current authentication state directly from storage
|
||
// This works for ALL methods including extensions (fixes the bug)
|
||
clearAuthState();
|
||
|
||
// Dispatch logout event for UI updates
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new CustomEvent('nlLogout', {
|
||
detail: { timestamp: Date.now() }
|
||
}));
|
||
}
|
||
}
|
||
|
||
// CSS-only theme switching
|
||
switchTheme(themeName) {
|
||
console.log('NOSTR_LOGIN_LITE: Switching to ' + themeName + ' theme');
|
||
|
||
if (THEME_CSS[themeName]) {
|
||
injectThemeCSS(themeName);
|
||
this.currentTheme = themeName;
|
||
|
||
// Dispatch theme change event
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new CustomEvent('nlThemeChanged', {
|
||
detail: { theme: themeName }
|
||
}));
|
||
}
|
||
|
||
return { theme: themeName };
|
||
} else {
|
||
console.warn("Theme '" + themeName + "' not found, using default");
|
||
injectThemeCSS('default');
|
||
this.currentTheme = 'default';
|
||
return { theme: 'default' };
|
||
}
|
||
}
|
||
|
||
getCurrentTheme() {
|
||
return this.currentTheme;
|
||
}
|
||
|
||
getAvailableThemes() {
|
||
return Object.keys(THEME_CSS);
|
||
}
|
||
|
||
embed(container, options = {}) {
|
||
console.log('NOSTR_LOGIN_LITE: Creating embedded modal in container:', container);
|
||
|
||
const embedOptions = {
|
||
...this.options,
|
||
...options,
|
||
embedded: container
|
||
};
|
||
|
||
// Create new modal instance for embedding
|
||
const embeddedModal = new Modal(embedOptions);
|
||
embeddedModal.open();
|
||
|
||
return embeddedModal;
|
||
}
|
||
|
||
// Floating tab management methods
|
||
showFloatingTab() {
|
||
if (this.floatingTab) {
|
||
this.floatingTab.show();
|
||
} else {
|
||
console.warn('NOSTR_LOGIN_LITE: Floating tab not enabled');
|
||
}
|
||
}
|
||
|
||
hideFloatingTab() {
|
||
if (this.floatingTab) {
|
||
this.floatingTab.hide();
|
||
}
|
||
}
|
||
|
||
toggleFloatingTab() {
|
||
if (this.floatingTab) {
|
||
if (this.floatingTab.isVisible) {
|
||
this.floatingTab.hide();
|
||
} else {
|
||
this.floatingTab.show();
|
||
}
|
||
}
|
||
}
|
||
|
||
updateFloatingTab(options) {
|
||
if (this.floatingTab) {
|
||
this.floatingTab.updateOptions(options);
|
||
}
|
||
}
|
||
|
||
getFloatingTabState() {
|
||
return this.floatingTab ? this.floatingTab.getState() : null;
|
||
}
|
||
}
|
||
|
||
// ======================================
|
||
// Simplified Authentication Manager (Unified Plaintext Storage)
|
||
// ======================================
|
||
|
||
// Simple authentication state manager - plaintext storage for maximum usability
|
||
class AuthManager {
|
||
constructor(options = {}) {
|
||
this.storageKey = 'nostr_login_lite_auth';
|
||
this.currentAuthState = null;
|
||
|
||
// Configure storage type based on isolateSession option
|
||
if (options.isolateSession) {
|
||
this.storage = sessionStorage;
|
||
// console.log('🔐 AuthManager: Using sessionStorage for per-window isolation');
|
||
} else {
|
||
this.storage = localStorage;
|
||
// console.log('🔐 AuthManager: Using localStorage for cross-window persistence');
|
||
}
|
||
|
||
console.warn('🔐 SECURITY: Private keys stored unencrypted in browser storage');
|
||
console.warn('🔐 For production apps, implement your own secure storage');
|
||
}
|
||
|
||
// Save authentication state using unified plaintext approach
|
||
async saveAuthState(authData) {
|
||
try {
|
||
|
||
|
||
const authState = {
|
||
method: authData.method,
|
||
timestamp: Date.now(),
|
||
pubkey: authData.pubkey
|
||
};
|
||
|
||
switch (authData.method) {
|
||
case 'extension':
|
||
// For extensions, only store verification data - no secrets
|
||
authState.extensionVerification = {
|
||
constructor: authData.extension?.constructor?.name,
|
||
hasGetPublicKey: typeof authData.extension?.getPublicKey === 'function',
|
||
hasSignEvent: typeof authData.extension?.signEvent === 'function'
|
||
};
|
||
|
||
break;
|
||
|
||
case 'local':
|
||
// UNIFIED PLAINTEXT: Store secret key directly for maximum compatibility
|
||
if (authData.secret) {
|
||
authState.secret = authData.secret;
|
||
|
||
}
|
||
break;
|
||
|
||
case 'nip46':
|
||
// For NIP-46, store connection parameters including secret to allow auto-reconnect
|
||
if (authData.signer) {
|
||
authState.nip46 = {
|
||
remotePubkey: authData.signer.remotePubkey,
|
||
relays: authData.signer.relays,
|
||
secret: authData.signer.secret,
|
||
localSecretKey: authData.signer.localSecretKey // We need to store this too
|
||
};
|
||
|
||
}
|
||
break;
|
||
|
||
case 'nsigner':
|
||
if (authData.signer) {
|
||
authState.nsigner = {
|
||
transport: String(authData.signer.transport || 'webusb').toLowerCase(),
|
||
nostrIndex: Number(authData.signer.nostrIndex ?? 0),
|
||
callerSecretKey: authData.signer.callerSecretKey,
|
||
callerPubkey: authData.signer.callerPubkey,
|
||
deviceVid: authData.signer.deviceVid,
|
||
deviceProductId: authData.signer.deviceProductId,
|
||
deviceSerial: authData.signer.deviceSerial || null
|
||
};
|
||
}
|
||
break;
|
||
|
||
case 'readonly':
|
||
// Read-only mode has no secrets to store
|
||
// console.log('🔐 AuthManager: Read-only method - storing basic auth state');
|
||
break;
|
||
|
||
default:
|
||
throw new Error('Unknown auth method: ' + authData.method);
|
||
}
|
||
|
||
this.storage.setItem(this.storageKey, JSON.stringify(authState));
|
||
this.currentAuthState = authState;
|
||
// console.log('🔐 AuthManager: Auth state saved successfully for method:', authData.method);
|
||
|
||
} catch (error) {
|
||
console.error('🔐 AuthManager: Failed to save auth state:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Restore authentication state on page load
|
||
async restoreAuthState() {
|
||
try {
|
||
|
||
|
||
const stored = this.storage.getItem(this.storageKey);
|
||
|
||
|
||
if (!stored) {
|
||
console.log('🔍 AuthManager: ❌ No stored auth state found');
|
||
return null;
|
||
}
|
||
|
||
const authState = JSON.parse(stored);
|
||
|
||
|
||
// Check if stored state is too old (24 hours for most methods, 1 hour for extensions)
|
||
const maxAge = authState.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
|
||
console.log('🔍 AuthManager: Max age for method:', maxAge, 'ms');
|
||
|
||
if (Date.now() - authState.timestamp > maxAge) {
|
||
console.log('🔍 AuthManager: ❌ Stored auth state expired, clearing');
|
||
this.clearAuthState();
|
||
return null;
|
||
}
|
||
|
||
|
||
let result;
|
||
switch (authState.method) {
|
||
case 'extension':
|
||
|
||
result = await this._restoreExtensionAuth(authState);
|
||
break;
|
||
|
||
case 'local':
|
||
|
||
result = await this._restoreLocalAuth(authState);
|
||
break;
|
||
|
||
case 'nip46':
|
||
|
||
result = await this._restoreNip46Auth(authState);
|
||
break;
|
||
|
||
case 'nsigner':
|
||
|
||
result = await this._restoreNSignerAuth(authState);
|
||
break;
|
||
|
||
case 'readonly':
|
||
|
||
result = await this._restoreReadonlyAuth(authState);
|
||
break;
|
||
|
||
default:
|
||
console.warn('🔍 AuthManager: ❌ Unknown auth method in stored state:', authState.method);
|
||
return null;
|
||
}
|
||
|
||
return result;
|
||
|
||
} catch (error) {
|
||
console.error('🔍 AuthManager: ❌ Failed to restore auth state:', error);
|
||
console.error('🔍 AuthManager: Error stack:', error.stack);
|
||
this.clearAuthState(); // Clear corrupted state
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async _restoreExtensionAuth(authState) {
|
||
console.log('🔍 AuthManager: === _restoreExtensionAuth START ===');
|
||
console.log('🔍 AuthManager: authState:', authState);
|
||
console.log('🔍 AuthManager: window.nostr available:', !!window.nostr);
|
||
console.log('🔍 AuthManager: window.nostr constructor:', window.nostr?.constructor?.name);
|
||
|
||
// SMART EXTENSION WAITING SYSTEM
|
||
// Extensions often load after our library, so we need to wait for them
|
||
const extension = await this._waitForExtension(authState, 3000); // Wait up to 3 seconds
|
||
|
||
if (!extension) {
|
||
console.log('🔍 AuthManager: ❌ No extension found after waiting');
|
||
return null;
|
||
}
|
||
|
||
|
||
|
||
try {
|
||
// Verify extension still works and has same pubkey
|
||
const currentPubkey = await extension.getPublicKey();
|
||
|
||
|
||
|
||
return {
|
||
method: 'extension',
|
||
pubkey: authState.pubkey,
|
||
extension: extension
|
||
};
|
||
|
||
} catch (error) {
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Smart extension waiting system - polls multiple locations for extensions
|
||
async _waitForExtension(authState, maxWaitMs = 3000) {
|
||
|
||
|
||
const startTime = Date.now();
|
||
const pollInterval = 100; // Check every 100ms
|
||
|
||
// Extension locations to check (in priority order)
|
||
const extensionLocations = [
|
||
{ path: 'window.nostr', getter: () => window.nostr },
|
||
{ path: 'navigator.nostr', getter: () => navigator?.nostr },
|
||
{ path: 'window.navigator?.nostr', getter: () => window.navigator?.nostr },
|
||
{ path: 'window.alby?.nostr', getter: () => window.alby?.nostr },
|
||
{ path: 'window.webln?.nostr', getter: () => window.webln?.nostr },
|
||
{ path: 'window.nos2x', getter: () => window.nos2x },
|
||
{ path: 'window.flamingo?.nostr', getter: () => window.flamingo?.nostr },
|
||
{ path: 'window.mutiny?.nostr', getter: () => window.mutiny?.nostr }
|
||
];
|
||
|
||
while (Date.now() - startTime < maxWaitMs) {
|
||
|
||
|
||
// If our facade is currently installed and blocking, temporarily remove it
|
||
let facadeRemoved = false;
|
||
let originalNostr = null;
|
||
if (window.nostr?.constructor?.name === 'WindowNostr') {
|
||
|
||
originalNostr = window.nostr;
|
||
window.nostr = window.nostr.existingNostr || undefined;
|
||
facadeRemoved = true;
|
||
}
|
||
|
||
try {
|
||
// Check all extension locations
|
||
for (const location of extensionLocations) {
|
||
try {
|
||
const extension = location.getter();
|
||
|
||
|
||
if (this._isValidExtensionForRestore(extension, authState)) {
|
||
|
||
|
||
// Restore facade if we removed it
|
||
if (facadeRemoved && originalNostr) {
|
||
|
||
window.nostr = originalNostr;
|
||
}
|
||
|
||
return extension;
|
||
}
|
||
} catch (error) {
|
||
|
||
}
|
||
}
|
||
|
||
// Restore facade if we removed it and haven't found an extension yet
|
||
if (facadeRemoved && originalNostr) {
|
||
window.nostr = originalNostr;
|
||
facadeRemoved = false;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('🔍 AuthManager: Error during extension polling:', error);
|
||
|
||
// Restore facade if we removed it
|
||
if (facadeRemoved && originalNostr) {
|
||
window.nostr = originalNostr;
|
||
}
|
||
}
|
||
|
||
// Wait before next poll
|
||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||
}
|
||
|
||
console.log('🔍 AuthManager: ❌ Extension waiting timeout after', maxWaitMs, 'ms');
|
||
return null;
|
||
}
|
||
|
||
// Check if an extension is valid for restoration
|
||
_isValidExtensionForRestore(extension, authState) {
|
||
if (!extension || typeof extension !== 'object') {
|
||
return false;
|
||
}
|
||
|
||
// Must have required Nostr methods
|
||
if (typeof extension.getPublicKey !== 'function' ||
|
||
typeof extension.signEvent !== 'function') {
|
||
return false;
|
||
}
|
||
|
||
// Must not be our own classes
|
||
const constructorName = extension.constructor?.name;
|
||
if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') {
|
||
return false;
|
||
}
|
||
|
||
// Must not be NostrTools
|
||
if (extension === window.NostrTools) {
|
||
return false;
|
||
}
|
||
|
||
// If we have stored verification data, check constructor match
|
||
const verification = authState.extensionVerification;
|
||
if (verification && verification.constructor) {
|
||
if (constructorName !== verification.constructor) {
|
||
console.log('🔍 AuthManager: Constructor mismatch -',
|
||
'expected:', verification.constructor,
|
||
'got:', constructorName);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
|
||
return true;
|
||
}
|
||
|
||
async _restoreLocalAuth(authState) {
|
||
|
||
if (authState.encrypted) {
|
||
|
||
// Try to decrypt legacy format
|
||
const sessionPassword = sessionStorage.getItem('nostr_session_key');
|
||
if (!sessionPassword) {
|
||
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
|
||
this.clearAuthState(); // Clear legacy format
|
||
return null;
|
||
} catch (error) {
|
||
|
||
this.clearAuthState(); // Clear corrupted legacy format
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// NEW UNIFIED PLAINTEXT FORMAT
|
||
if (!authState.secret) {
|
||
|
||
return null;
|
||
}
|
||
|
||
|
||
|
||
return {
|
||
method: 'local',
|
||
pubkey: authState.pubkey,
|
||
secret: authState.secret
|
||
};
|
||
}
|
||
|
||
async _restoreNip46Auth(authState) {
|
||
if (!authState.nip46) {
|
||
|
||
return null;
|
||
}
|
||
|
||
// If we have the local secret key and remote pubkey, we can auto-reconnect
|
||
if (authState.nip46.localSecretKey && authState.nip46.remotePubkey) {
|
||
try {
|
||
if (!window.NostrTools?.nip46) {
|
||
throw new Error('nostr-tools NIP-46 module not available');
|
||
}
|
||
|
||
const localSecretKey = window.NostrTools.nip19.decode(authState.nip46.localSecretKey).data;
|
||
|
||
// Reconstruct bunker pointer
|
||
const bunkerPointer = {
|
||
pubkey: authState.nip46.remotePubkey,
|
||
relays: authState.nip46.relays || [],
|
||
secret: authState.nip46.secret
|
||
};
|
||
|
||
// Create a SimplePool with a patched subscribe that filters out stale cached
|
||
// NIP-46 response events. Relays like primal.net store and replay previous
|
||
// "unauthorized" responses before we've even sent our request.
|
||
const sessionStart = Math.floor(Date.now() / 1000);
|
||
const gate = { open: false };
|
||
const signerRef = { current: null }; // forward ref so the closure can access signer after it's created
|
||
const pool = new window.NostrTools.SimplePool();
|
||
const origSubscribe = pool.subscribe.bind(pool);
|
||
pool.subscribe = (relays, filter, subParams) => {
|
||
const origOnevent = subParams.onevent;
|
||
subParams.onevent = async (event) => {
|
||
if (!gate.open) return; // drop events arriving before we open the gate
|
||
// Also skip if we can decrypt and see it's a response to a request id
|
||
// that we haven't sent yet (i.e. its id doesn't match any pending listener)
|
||
try {
|
||
const s = signerRef.current;
|
||
if (!s) return; // signer not yet created, drop
|
||
const decrypted = JSON.parse(window.NostrTools.nip44.decrypt(event.content, s.conversationKey));
|
||
if (decrypted.id && !s.listeners[decrypted.id]) {
|
||
// No pending listener for this id — it's stale, drop it
|
||
return;
|
||
}
|
||
} catch (_) {
|
||
// Can't decrypt — not for us, drop it
|
||
return;
|
||
}
|
||
return origOnevent(event);
|
||
};
|
||
return origSubscribe(relays, { ...filter, since: sessionStart }, subParams);
|
||
};
|
||
|
||
// Use nostr-tools BunkerSigner factory method, passing our patched pool
|
||
const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(localSecretKey, bunkerPointer, {
|
||
pool,
|
||
onauth: (url) => {
|
||
window.open(url, '_blank', 'width=600,height=800');
|
||
}
|
||
});
|
||
signerRef.current = signer; // allow the onevent closure to access the signer
|
||
|
||
// Open the gate to allow events through
|
||
gate.open = true;
|
||
|
||
// We don't need to call connect() again if we already have the pubkey and secret,
|
||
// but we do need to ensure the signer is ready to sign.
|
||
// The BunkerSigner will automatically subscribe to the relays when we call signEvent.
|
||
|
||
return {
|
||
method: 'nip46',
|
||
pubkey: authState.pubkey,
|
||
signer: {
|
||
method: 'nip46',
|
||
remotePubkey: authState.nip46.remotePubkey,
|
||
bunkerSigner: signer,
|
||
secret: authState.nip46.secret,
|
||
relays: authState.nip46.relays
|
||
}
|
||
};
|
||
} catch (error) {
|
||
console.error('🔍 AuthManager: Failed to auto-reconnect NIP-46:', error);
|
||
// Fall back to requiring reconnection
|
||
}
|
||
}
|
||
|
||
// For NIP-46, if we can't auto-reconnect, we return the connection parameters
|
||
// so the UI can prompt for reconnection
|
||
return {
|
||
method: 'nip46',
|
||
pubkey: authState.pubkey,
|
||
requiresReconnection: true,
|
||
connectionData: authState.nip46
|
||
};
|
||
}
|
||
|
||
async _restoreNSignerAuth(authState) {
|
||
const nsigner = authState.nsigner;
|
||
if (!nsigner) return null;
|
||
|
||
const transport = String(nsigner.transport || 'webusb').toLowerCase();
|
||
const isWebSerial = transport === 'webserial';
|
||
|
||
if (!window.isSecureContext) {
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
requiresReconnection: true,
|
||
connectionData: nsigner,
|
||
message: 'n_signer requires HTTPS or localhost.'
|
||
};
|
||
}
|
||
|
||
if (isWebSerial) {
|
||
if (!('serial' in navigator) || !window.NSignerWebSerial) {
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
requiresReconnection: true,
|
||
connectionData: nsigner,
|
||
message: 'CYD n_signer requires Web Serial (Chrome 89+, Edge 89+, or Brave).'
|
||
};
|
||
}
|
||
|
||
const paired = await window.NSignerWebSerial.getPairedDevice({
|
||
vendorId: nsigner.deviceVid ?? null,
|
||
productId: nsigner.deviceProductId ?? null
|
||
});
|
||
|
||
if (!paired) {
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
requiresReconnection: true,
|
||
connectionData: nsigner,
|
||
message: 'Your CYD n_signer device is not connected. Plug it in and reconnect to continue.'
|
||
};
|
||
}
|
||
|
||
try {
|
||
const driver = new window.NSignerWebSerial(paired, {
|
||
callerSecretKey: nsigner.callerSecretKey,
|
||
nostrIndex: Number(nsigner.nostrIndex ?? 0)
|
||
});
|
||
|
||
await driver.open();
|
||
const restoredPubkey = await driver.getPublicKey();
|
||
if (restoredPubkey !== String(authState.pubkey || '').toLowerCase()) {
|
||
await driver.close();
|
||
this.clearAuthState();
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
signer: {
|
||
driver,
|
||
transport: 'webserial',
|
||
nostrIndex: Number(nsigner.nostrIndex ?? 0),
|
||
callerSecretKey: nsigner.callerSecretKey,
|
||
callerPubkey: nsigner.callerPubkey,
|
||
deviceVid: nsigner.deviceVid,
|
||
deviceProductId: nsigner.deviceProductId,
|
||
deviceSerial: null
|
||
}
|
||
};
|
||
} catch (err) {
|
||
const msg = String(err?.message || err || '');
|
||
const likelyBusy = /in use|already open|networkerror|failed to open/i.test(msg);
|
||
|
||
if (likelyBusy) {
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
signer: {
|
||
driver: null,
|
||
delegatedOnly: true,
|
||
transport: 'webserial',
|
||
nostrIndex: Number(nsigner.nostrIndex ?? 0),
|
||
callerSecretKey: nsigner.callerSecretKey,
|
||
callerPubkey: nsigner.callerPubkey,
|
||
deviceVid: nsigner.deviceVid,
|
||
deviceProductId: nsigner.deviceProductId,
|
||
deviceSerial: null
|
||
},
|
||
message: 'CYD n_signer appears to be connected in another tab. Use that tab or delegate signing through your SharedWorker.'
|
||
};
|
||
}
|
||
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
requiresReconnection: true,
|
||
connectionData: nsigner,
|
||
message: 'Could not reopen your CYD n_signer serial port. Reconnect from the modal.'
|
||
};
|
||
}
|
||
}
|
||
|
||
if (!('usb' in navigator) || !window.NSignerWebUSB) {
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
requiresReconnection: true,
|
||
connectionData: nsigner,
|
||
message: 'n_signer requires a WebUSB-capable browser (Chrome/Edge).'
|
||
};
|
||
}
|
||
|
||
const paired = await window.NSignerWebUSB.getPairedDevice({
|
||
vendorId: nsigner.deviceVid ?? null,
|
||
productId: nsigner.deviceProductId ?? null,
|
||
serialNumber: nsigner.deviceSerial || undefined
|
||
});
|
||
|
||
if (!paired) {
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
requiresReconnection: true,
|
||
connectionData: nsigner,
|
||
message: 'Your n_signer device is not connected. Plug it in and reconnect to continue.'
|
||
};
|
||
}
|
||
|
||
try {
|
||
const driver = new window.NSignerWebUSB(paired, {
|
||
callerSecretKey: nsigner.callerSecretKey,
|
||
nostrIndex: Number(nsigner.nostrIndex ?? 0)
|
||
});
|
||
|
||
await driver.open();
|
||
const restoredPubkey = await driver.getPublicKey();
|
||
if (restoredPubkey !== String(authState.pubkey || '').toLowerCase()) {
|
||
await driver.close();
|
||
this.clearAuthState();
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
signer: {
|
||
driver,
|
||
transport: 'webusb',
|
||
nostrIndex: Number(nsigner.nostrIndex ?? 0),
|
||
callerSecretKey: nsigner.callerSecretKey,
|
||
callerPubkey: nsigner.callerPubkey,
|
||
deviceVid: nsigner.deviceVid,
|
||
deviceProductId: nsigner.deviceProductId,
|
||
deviceSerial: nsigner.deviceSerial || null
|
||
}
|
||
};
|
||
} catch (err) {
|
||
const msg = String(err?.message || err || '');
|
||
const likelyBusy = /in use|already open|networkerror|unable to claim|claiminterface/i.test(msg);
|
||
|
||
if (likelyBusy) {
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
signer: {
|
||
driver: null,
|
||
delegatedOnly: true,
|
||
transport: 'webusb',
|
||
nostrIndex: Number(nsigner.nostrIndex ?? 0),
|
||
callerSecretKey: nsigner.callerSecretKey,
|
||
callerPubkey: nsigner.callerPubkey,
|
||
deviceVid: nsigner.deviceVid,
|
||
deviceProductId: nsigner.deviceProductId,
|
||
deviceSerial: nsigner.deviceSerial || null
|
||
},
|
||
message: 'n_signer appears to be connected in another tab. Use that tab or delegate signing through your SharedWorker.'
|
||
};
|
||
}
|
||
|
||
return {
|
||
method: 'nsigner',
|
||
pubkey: authState.pubkey,
|
||
requiresReconnection: true,
|
||
connectionData: nsigner,
|
||
message: 'Could not reopen your n_signer device. Reconnect from the modal.'
|
||
};
|
||
}
|
||
}
|
||
|
||
async _restoreReadonlyAuth(authState) {
|
||
|
||
return {
|
||
method: 'readonly',
|
||
pubkey: authState.pubkey
|
||
};
|
||
}
|
||
|
||
// Clear stored authentication state
|
||
clearAuthState() {
|
||
this.storage.removeItem(this.storageKey);
|
||
sessionStorage.removeItem('nostr_session_key'); // Clear legacy session key
|
||
this.currentAuthState = null;
|
||
|
||
}
|
||
|
||
// Check if we have valid stored auth
|
||
hasStoredAuth() {
|
||
const stored = this.storage.getItem(this.storageKey);
|
||
return !!stored;
|
||
}
|
||
|
||
// Get current auth method without full restoration
|
||
getStoredAuthMethod() {
|
||
try {
|
||
const stored = this.storage.getItem(this.storageKey);
|
||
if (!stored) return null;
|
||
|
||
const authState = JSON.parse(stored);
|
||
return authState.method;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ======================================
|
||
// Global Authentication Functions (Single Source of Truth)
|
||
// ======================================
|
||
|
||
// Global authentication state (single source of truth)
|
||
let globalAuthState = null;
|
||
let globalAuthManager = null;
|
||
|
||
// Initialize global auth manager (lazy initialization)
|
||
function getGlobalAuthManager() {
|
||
if (!globalAuthManager) {
|
||
// Default to localStorage for persistence across browser sessions
|
||
globalAuthManager = new AuthManager({ isolateSession: false });
|
||
}
|
||
return globalAuthManager;
|
||
}
|
||
|
||
// **UNIFIED GLOBAL FUNCTION**: Set authentication state (works for all methods)
|
||
function setAuthState(authData, options = {}) {
|
||
try {
|
||
|
||
|
||
|
||
// Store in memory
|
||
globalAuthState = authData;
|
||
|
||
// Store in browser storage using AuthManager
|
||
const authManager = new AuthManager(options);
|
||
authManager.saveAuthState(authData);
|
||
|
||
|
||
} catch (error) {
|
||
console.error('🌐 setAuthState: Failed to save auth state:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// **UNIFIED GLOBAL FUNCTION**: Get authentication state (single source of truth)
|
||
function getAuthState() {
|
||
try {
|
||
// Always query from storage as the authoritative source
|
||
const authManager = getGlobalAuthManager();
|
||
const storageKey = 'nostr_login_lite_auth';
|
||
|
||
// Check both session and local storage for compatibility
|
||
let stored = null;
|
||
if (sessionStorage.getItem(storageKey)) {
|
||
stored = sessionStorage.getItem(storageKey);
|
||
} else if (localStorage.getItem(storageKey)) {
|
||
stored = localStorage.getItem(storageKey);
|
||
}
|
||
|
||
if (!stored) {
|
||
// console.log('🌐 getAuthState: No auth state found in storage');
|
||
globalAuthState = null;
|
||
return null;
|
||
}
|
||
|
||
const authState = JSON.parse(stored);
|
||
// console.log('🌐 getAuthState: Retrieved auth state:', authState.method);
|
||
|
||
// Update in-memory cache
|
||
globalAuthState = authState;
|
||
return authState;
|
||
|
||
} catch (error) {
|
||
console.error('🌐 getAuthState: Failed to get auth state:', error);
|
||
globalAuthState = null;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// **UNIFIED GLOBAL FUNCTION**: Clear authentication state (works for all methods)
|
||
function clearAuthState() {
|
||
try {
|
||
// console.log('🌐 clearAuthState: Clearing global auth state');
|
||
|
||
// Clear in-memory state
|
||
globalAuthState = null;
|
||
|
||
// Clear from both storage types for thorough cleanup
|
||
const storageKey = 'nostr_login_lite_auth';
|
||
localStorage.removeItem(storageKey);
|
||
sessionStorage.removeItem(storageKey);
|
||
sessionStorage.removeItem('nostr_session_key'); // Clear legacy session key
|
||
|
||
// console.log('🌐 clearAuthState: Auth state cleared from all storage locations');
|
||
} catch (error) {
|
||
console.error('🌐 clearAuthState: Failed to clear auth state:', error);
|
||
}
|
||
}
|
||
|
||
// NIP-07 compliant window.nostr provider
|
||
class WindowNostr {
|
||
constructor(nostrLite, existingNostr = null, options = {}) {
|
||
this.nostrLite = nostrLite;
|
||
this.authState = null;
|
||
this.existingNostr = existingNostr;
|
||
this.authenticatedExtension = null;
|
||
this.options = options;
|
||
this._setupEventListeners();
|
||
}
|
||
|
||
// Restore authentication state on page load
|
||
async restoreAuthState() {
|
||
console.log('🔍 WindowNostr: === restoreAuthState ===');
|
||
|
||
try {
|
||
// Use simplified AuthManager for consistent restore logic
|
||
const authManager = new AuthManager(this.options);
|
||
const restoredAuth = await authManager.restoreAuthState();
|
||
|
||
if (restoredAuth) {
|
||
console.log('🔍 WindowNostr: ✅ Auth state restored:', restoredAuth.method);
|
||
this.authState = restoredAuth;
|
||
|
||
// Update global state
|
||
globalAuthState = restoredAuth;
|
||
|
||
// Dispatch restoration event
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new CustomEvent('nlAuthRestored', {
|
||
detail: restoredAuth
|
||
}));
|
||
}
|
||
|
||
return restoredAuth;
|
||
} else {
|
||
console.log('🔍 WindowNostr: ❌ No auth state to restore');
|
||
return null;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('🔍 WindowNostr: Auth restoration failed:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
_setupEventListeners() {
|
||
// Listen for authentication events to store auth state
|
||
if (typeof window !== 'undefined') {
|
||
window.addEventListener('nlMethodSelected', async (event) => {
|
||
console.log('🔍 WindowNostr: nlMethodSelected event received:', event.detail);
|
||
this.authState = event.detail;
|
||
|
||
// If extension method, capture the specific extension the user chose
|
||
if (event.detail.method === 'extension') {
|
||
this.authenticatedExtension = event.detail.extension;
|
||
console.log('🔍 WindowNostr: Captured authenticated extension:', this.authenticatedExtension?.constructor?.name);
|
||
}
|
||
|
||
// Use unified global setAuthState function for all methods
|
||
try {
|
||
setAuthState(event.detail, this.options);
|
||
console.log('🔍 WindowNostr: Auth state saved via unified setAuthState');
|
||
} catch (error) {
|
||
console.error('🔍 WindowNostr: Failed to save auth state:', error);
|
||
}
|
||
});
|
||
|
||
window.addEventListener('nlLogout', async () => {
|
||
console.log('🔍 WindowNostr: nlLogout event received');
|
||
|
||
if (this.authState?.method === 'nsigner' && this.authState?.signer?.driver?.close) {
|
||
try { await this.authState.signer.driver.close(); } catch (_) {}
|
||
}
|
||
|
||
this.authState = null;
|
||
this.authenticatedExtension = null;
|
||
|
||
// Clear from unified storage
|
||
clearAuthState();
|
||
console.log('🔍 WindowNostr: Auth state cleared via unified clearAuthState');
|
||
});
|
||
}
|
||
}
|
||
|
||
async getPublicKey() {
|
||
if (!this.authState) {
|
||
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
|
||
switch (this.authState.method) {
|
||
case 'extension':
|
||
// Use the captured authenticated extension, not current window.nostr
|
||
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
||
if (!ext) throw new Error('Extension not available');
|
||
return await ext.getPublicKey();
|
||
|
||
case 'local':
|
||
case 'nip46':
|
||
return this.authState.pubkey;
|
||
|
||
case 'nsigner':
|
||
return this.authState.pubkey;
|
||
|
||
case 'readonly':
|
||
throw new Error('Read-only mode - cannot get public key');
|
||
|
||
default:
|
||
throw new Error('Unsupported auth method: ' + this.authState.method);
|
||
}
|
||
}
|
||
|
||
async signEvent(event) {
|
||
if (!this.authState) {
|
||
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
|
||
if (this.authState.method === 'readonly') {
|
||
throw new Error('Read-only mode - cannot sign events');
|
||
}
|
||
|
||
switch (this.authState.method) {
|
||
case 'extension':
|
||
// Use the captured authenticated extension, not current window.nostr
|
||
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
||
if (!ext) throw new Error('Extension not available');
|
||
return await ext.signEvent(event);
|
||
|
||
case 'local': {
|
||
// Use nostr-tools to sign with local secret key
|
||
const { nip19, finalizeEvent } = window.NostrTools;
|
||
let secretKey;
|
||
|
||
if (this.authState.secret.startsWith('nsec')) {
|
||
const decoded = nip19.decode(this.authState.secret);
|
||
secretKey = decoded.data;
|
||
} else {
|
||
// Convert hex to Uint8Array
|
||
secretKey = this._hexToUint8Array(this.authState.secret);
|
||
}
|
||
|
||
return finalizeEvent(event, secretKey);
|
||
}
|
||
|
||
case 'nip46': {
|
||
// Use BunkerSigner for NIP-46
|
||
if (!this.authState.signer?.bunkerSigner) {
|
||
throw new Error('NIP-46 signer not available');
|
||
}
|
||
return await this.authState.signer.bunkerSigner.signEvent(event);
|
||
}
|
||
|
||
case 'nsigner': {
|
||
const driver = this._getNSignerDriver('signEvent');
|
||
return await driver.signEvent(event);
|
||
}
|
||
|
||
default:
|
||
throw new Error('Unsupported auth method: ' + this.authState.method);
|
||
}
|
||
}
|
||
|
||
async getRelays() {
|
||
// Return configured relays from nostr-lite options
|
||
return this.nostrLite.options?.relays || ['wss://relay.damus.io'];
|
||
}
|
||
|
||
get nip04() {
|
||
return {
|
||
encrypt: async (pubkey, plaintext) => {
|
||
if (!this.authState) {
|
||
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
|
||
if (this.authState.method === 'readonly') {
|
||
throw new Error('Read-only mode - cannot encrypt');
|
||
}
|
||
|
||
switch (this.authState.method) {
|
||
case 'extension': {
|
||
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
||
if (!ext) throw new Error('Extension not available');
|
||
return await ext.nip04.encrypt(pubkey, plaintext);
|
||
}
|
||
|
||
case 'local': {
|
||
const { nip04, nip19 } = window.NostrTools;
|
||
let secretKey;
|
||
|
||
if (this.authState.secret.startsWith('nsec')) {
|
||
const decoded = nip19.decode(this.authState.secret);
|
||
secretKey = decoded.data;
|
||
} else {
|
||
secretKey = this._hexToUint8Array(this.authState.secret);
|
||
}
|
||
|
||
return await nip04.encrypt(secretKey, pubkey, plaintext);
|
||
}
|
||
|
||
case 'nip46': {
|
||
if (!this.authState.signer?.bunkerSigner) {
|
||
throw new Error('NIP-46 signer not available');
|
||
}
|
||
return await this.authState.signer.bunkerSigner.nip04Encrypt(pubkey, plaintext);
|
||
}
|
||
|
||
case 'nsigner': {
|
||
const driver = this._getNSignerDriver('nip04.encrypt');
|
||
return await driver.nip04Encrypt(pubkey, plaintext);
|
||
}
|
||
|
||
default:
|
||
throw new Error('Unsupported auth method: ' + this.authState.method);
|
||
}
|
||
},
|
||
|
||
decrypt: async (pubkey, ciphertext) => {
|
||
if (!this.authState) {
|
||
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
|
||
if (this.authState.method === 'readonly') {
|
||
throw new Error('Read-only mode - cannot decrypt');
|
||
}
|
||
|
||
switch (this.authState.method) {
|
||
case 'extension': {
|
||
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
||
if (!ext) throw new Error('Extension not available');
|
||
return await ext.nip04.decrypt(pubkey, ciphertext);
|
||
}
|
||
|
||
case 'local': {
|
||
const { nip04, nip19 } = window.NostrTools;
|
||
let secretKey;
|
||
|
||
if (this.authState.secret.startsWith('nsec')) {
|
||
const decoded = nip19.decode(this.authState.secret);
|
||
secretKey = decoded.data;
|
||
} else {
|
||
secretKey = this._hexToUint8Array(this.authState.secret);
|
||
}
|
||
|
||
return await nip04.decrypt(secretKey, pubkey, ciphertext);
|
||
}
|
||
|
||
case 'nip46': {
|
||
if (!this.authState.signer?.bunkerSigner) {
|
||
throw new Error('NIP-46 signer not available');
|
||
}
|
||
return await this.authState.signer.bunkerSigner.nip04Decrypt(pubkey, ciphertext);
|
||
}
|
||
|
||
case 'nsigner': {
|
||
const driver = this._getNSignerDriver('nip04.decrypt');
|
||
return await driver.nip04Decrypt(pubkey, ciphertext);
|
||
}
|
||
|
||
default:
|
||
throw new Error('Unsupported auth method: ' + this.authState.method);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
get nip44() {
|
||
return {
|
||
encrypt: async (pubkey, plaintext) => {
|
||
if (!this.authState) {
|
||
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
|
||
if (this.authState.method === 'readonly') {
|
||
throw new Error('Read-only mode - cannot encrypt');
|
||
}
|
||
|
||
switch (this.authState.method) {
|
||
case 'extension': {
|
||
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
||
if (!ext) throw new Error('Extension not available');
|
||
return await ext.nip44.encrypt(pubkey, plaintext);
|
||
}
|
||
|
||
case 'local': {
|
||
const { nip44, nip19 } = window.NostrTools;
|
||
let secretKey;
|
||
|
||
if (this.authState.secret.startsWith('nsec')) {
|
||
const decoded = nip19.decode(this.authState.secret);
|
||
secretKey = decoded.data;
|
||
} else {
|
||
secretKey = this._hexToUint8Array(this.authState.secret);
|
||
}
|
||
|
||
return nip44.encrypt(plaintext, nip44.getConversationKey(secretKey, pubkey));
|
||
}
|
||
|
||
case 'nip46': {
|
||
if (!this.authState.signer?.bunkerSigner) {
|
||
throw new Error('NIP-46 signer not available');
|
||
}
|
||
return await this.authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext);
|
||
}
|
||
|
||
case 'nsigner': {
|
||
const driver = this._getNSignerDriver('nip44.encrypt');
|
||
return await driver.nip44Encrypt(pubkey, plaintext);
|
||
}
|
||
|
||
default:
|
||
throw new Error('Unsupported auth method: ' + this.authState.method);
|
||
}
|
||
},
|
||
|
||
decrypt: async (pubkey, ciphertext) => {
|
||
if (!this.authState) {
|
||
throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');
|
||
}
|
||
|
||
if (this.authState.method === 'readonly') {
|
||
throw new Error('Read-only mode - cannot decrypt');
|
||
}
|
||
|
||
switch (this.authState.method) {
|
||
case 'extension': {
|
||
const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr;
|
||
if (!ext) throw new Error('Extension not available');
|
||
return await ext.nip44.decrypt(pubkey, ciphertext);
|
||
}
|
||
|
||
case 'local': {
|
||
const { nip44, nip19 } = window.NostrTools;
|
||
let secretKey;
|
||
|
||
if (this.authState.secret.startsWith('nsec')) {
|
||
const decoded = nip19.decode(this.authState.secret);
|
||
secretKey = decoded.data;
|
||
} else {
|
||
secretKey = this._hexToUint8Array(this.authState.secret);
|
||
}
|
||
|
||
return nip44.decrypt(ciphertext, nip44.getConversationKey(secretKey, pubkey));
|
||
}
|
||
|
||
case 'nip46': {
|
||
if (!this.authState.signer?.bunkerSigner) {
|
||
throw new Error('NIP-46 signer not available');
|
||
}
|
||
return await this.authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext);
|
||
}
|
||
|
||
case 'nsigner': {
|
||
const driver = this._getNSignerDriver('nip44.decrypt');
|
||
return await driver.nip44Decrypt(pubkey, ciphertext);
|
||
}
|
||
|
||
default:
|
||
throw new Error('Unsupported auth method: ' + this.authState.method);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
_getNSignerDriver(opName = 'operation') {
|
||
const driver = this.authState?.signer?.driver;
|
||
if (driver) return driver;
|
||
|
||
if (this.authState?.signer?.delegatedOnly) {
|
||
throw new Error('n_signer driver not available in this tab for ' + opName + '; signer is active in another tab');
|
||
}
|
||
|
||
throw new Error('n_signer driver not available');
|
||
}
|
||
|
||
_hexToUint8Array(hex) {
|
||
if (hex.length % 2 !== 0) {
|
||
throw new Error('Invalid hex string length');
|
||
}
|
||
const bytes = new Uint8Array(hex.length / 2);
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||
}
|
||
return bytes;
|
||
}
|
||
}
|
||
|
||
// Initialize and export
|
||
if (typeof window !== 'undefined') {
|
||
const nostrLite = new NostrLite();
|
||
|
||
// Export main API
|
||
window.NOSTR_LOGIN_LITE = {
|
||
init: (options) => nostrLite.init(options),
|
||
launch: (startScreen) => nostrLite.launch(startScreen),
|
||
logout: () => nostrLite.logout(),
|
||
|
||
// Embedded modal method
|
||
embed: (container, options) => nostrLite.embed(container, options),
|
||
|
||
// CSS-only theme management API
|
||
switchTheme: (themeName) => nostrLite.switchTheme(themeName),
|
||
getCurrentTheme: () => nostrLite.getCurrentTheme(),
|
||
getAvailableThemes: () => nostrLite.getAvailableThemes(),
|
||
|
||
// Floating tab management API
|
||
showFloatingTab: () => nostrLite.showFloatingTab(),
|
||
hideFloatingTab: () => nostrLite.hideFloatingTab(),
|
||
toggleFloatingTab: () => nostrLite.toggleFloatingTab(),
|
||
updateFloatingTab: (options) => nostrLite.updateFloatingTab(options),
|
||
getFloatingTabState: () => nostrLite.getFloatingTabState(),
|
||
|
||
// Global authentication state management (single source of truth)
|
||
setAuthState: setAuthState,
|
||
getAuthState: getAuthState,
|
||
clearAuthState: clearAuthState,
|
||
|
||
// Expose for debugging
|
||
_extensionBridge: nostrLite.extensionBridge,
|
||
_instance: nostrLite
|
||
};
|
||
|
||
// console.log('NOSTR_LOGIN_LITE: Library loaded and ready');
|
||
// console.log('NOSTR_LOGIN_LITE: Use window.NOSTR_LOGIN_LITE.init(options) to initialize');
|
||
// console.log('NOSTR_LOGIN_LITE: Detected', nostrLite.extensionBridge.getExtensionCount(), 'browser extensions');
|
||
console.warn('🔐 SECURITY: Unified plaintext storage enabled for maximum developer usability');
|
||
} else {
|
||
// Node.js environment
|
||
module.exports = { NostrLite };
|
||
}
|
||
|