signer.mx raw

   1  package main
   2  
   3  import (
   4  	"git.smesh.lol/musiquay/web/common/accum"
   5  	"git.smesh.lol/musiquay/web/common/helpers"
   6  	"git.smesh.lol/musiquay/web/common/jsbridge/callback"
   7  	"git.smesh.lol/musiquay/web/common/jsbridge/dom"
   8  	"git.smesh.lol/musiquay/web/common/jsbridge/localstorage"
   9  	"git.smesh.lol/musiquay/web/common/jsbridge/signer"
  10  )
  11  
  12  // Signer modal overlay inside the sm3sh app.
  13  // Communicates with the extension background via window.nostr.musiquay.
  14  
  15  var (
  16  	signerCBs        accum.Ints // callback IDs registered by render functions
  17  )
  18  
  19  // initSigner checks for the extension and sets up the signer button.
  20  func initSigner() {
  21  	signer.IsInstalled(func(ok bool) {
  22  		if !ok {
  23  			dom.ConsoleLog("signer worker not ready")
  24  			return
  25  		}
  26  		dom.ConsoleLog("signer worker ready")
  27  		refreshVaultStatus()
  28  	})
  29  }
  30  
  31  func refreshVaultStatus() {
  32  	signer.GetVaultStatus(func(status string) {
  33  		appSt.signerStatus = status
  34  	})
  35  }
  36  
  37  // buildSignerPanel creates the signer popover panel (hidden).
  38  // Called from buildStatusBar so both login and main app share it.
  39  func buildSignerPanel() {
  40  	appSt.signerPanel = dom.CreateElement("div")
  41  	dom.SetStyle(appSt.signerPanel, "position", "fixed")
  42  	dom.SetStyle(appSt.signerPanel, "bottom", "37px")
  43  	dom.SetStyle(appSt.signerPanel, "left", "12px") // re-aligned on toggle to the user button's left edge.
  44  	dom.SetStyle(appSt.signerPanel, "width", "360px")
  45  	dom.SetStyle(appSt.signerPanel, "maxWidth", "calc(100vw - 24px)")
  46  	dom.SetStyle(appSt.signerPanel, "maxHeight", "60vh")
  47  	dom.SetStyle(appSt.signerPanel, "overflowY", "auto")
  48  	dom.SetStyle(appSt.signerPanel, "background", "var(--bg2)")
  49  	dom.SetStyle(appSt.signerPanel, "border", "1px solid var(--border)")
  50  	dom.SetStyle(appSt.signerPanel, "borderRadius", "8px")
  51  	dom.SetStyle(appSt.signerPanel, "padding", "12px 16px")
  52  	dom.SetStyle(appSt.signerPanel, "fontSize", "13px")
  53  	dom.SetStyle(appSt.signerPanel, "display", "none")
  54  	dom.SetStyle(appSt.signerPanel, "zIndex", "99")
  55  
  56  	appSt.signerContent = dom.CreateElement("div")
  57  	dom.AppendChild(appSt.signerPanel, appSt.signerContent)
  58  
  59  	appSt.viewProfileRow = dom.CreateElement("div")
  60  	dom.SetStyle(appSt.viewProfileRow, "display", "none")
  61  	dom.SetStyle(appSt.viewProfileRow, "borderTop", "1px solid var(--border)")
  62  	dom.SetStyle(appSt.viewProfileRow, "marginTop", "12px")
  63  	dom.SetStyle(appSt.viewProfileRow, "paddingTop", "10px")
  64  	viewProfileLink := dom.CreateElement("span")
  65  	dom.SetTextContent(viewProfileLink, "view profile")
  66  	dom.SetStyle(viewProfileLink, "cursor", "pointer")
  67  	dom.SetStyle(viewProfileLink, "color", "var(--accent)")
  68  	dom.SetStyle(viewProfileLink, "fontFamily", "'Fira Code', monospace")
  69  	dom.AddEventListener(viewProfileLink, "click", dom.RegisterCallback(func() {
  70  		if appSt.pubhex == "" {
  71  			return
  72  		}
  73  		hideSignerPanel()
  74  		showProfile(appSt.pubhex)
  75  	}))
  76  	dom.AppendChild(appSt.viewProfileRow, viewProfileLink)
  77  	dom.AppendChild(appSt.signerPanel, appSt.viewProfileRow)
  78  }
  79  
  80  // toggleSignerPanel shows/hides the signer popover.
  81  func toggleSignerPanel() {
  82  	if appSt.signerOpen {
  83  		hideSignerPanel()
  84  		return
  85  	}
  86  	if appSt.popoverOpen {
  87  		togglePopover()
  88  	}
  89  	closeFeedPopover()
  90  	appSt.signerOpen = true
  91  	// Align panel's left edge with the user avatar button's left edge,
  92  	// clamped so the right edge stays on screen.
  93  	if int32(appSt.userBtn) != 0 {
  94  		x := dom.BoundingClientLeft(appSt.userBtn)
  95  		vw := dom.GetViewportWidth()
  96  		panelW := 360
  97  		if panelW > vw-12 {
  98  			panelW = vw - 12
  99  		}
 100  		if x+panelW > vw-6 {
 101  			x = vw - 6 - panelW
 102  		}
 103  		if x < 6 {
 104  			x = 6
 105  		}
 106  		dom.SetStyle(appSt.signerPanel, "left", itoa(x)|"px")
 107  	}
 108  	dom.SetStyle(appSt.signerPanel, "display", "block")
 109  	if int32(signerBtnEl) != 0 {
 110  		dom.SetStyle(signerBtnEl, "background", "var(--accent)")
 111  		dom.SetStyle(signerBtnEl, "color", "var(--bg)")
 112  	}
 113  	if int32(appSt.userBtn) != 0 {
 114  		dom.SetStyle(appSt.userBtn, "background", "var(--accent)")
 115  		dom.SetStyle(appSt.userBtn, "color", "#fff")
 116  	}
 117  	renderSignerUI()
 118  }
 119  
 120  func hideSignerPanel() {
 121  	if !appSt.signerOpen {
 122  		return
 123  	}
 124  	appSt.signerOpen = false
 125  	dom.SetStyle(appSt.signerPanel, "display", "none")
 126  	if int32(signerBtnEl) != 0 {
 127  		dom.SetStyle(signerBtnEl, "background", "transparent")
 128  		dom.SetStyle(signerBtnEl, "color", "var(--muted)")
 129  	}
 130  	if int32(appSt.userBtn) != 0 {
 131  		dom.SetStyle(appSt.userBtn, "background", "transparent")
 132  		dom.SetStyle(appSt.userBtn, "color", "var(--fg)")
 133  	}
 134  }
 135  
 136  func signerCB(fn func()) (n int32) {
 137  	id := dom.RegisterCallback(fn)
 138  	signerCBs.Push(id)
 139  	return id
 140  }
 141  
 142  func releaseSignerCBs() {
 143  	for i := int32(0); i < signerCBs.Len(); i++ {
 144  		callback.Release(signerCBs.At(i))
 145  	}
 146  	signerCBs.Reset()
 147  }
 148  
 149  func renderSignerUI() {
 150  	releaseSignerCBs()
 151  	dom.SetTextContent(appSt.signerContent, "")
 152  
 153  	if !signer.HasSigner() {
 154  		p := dom.CreateElement("p")
 155  		dom.SetStyle(p, "color", "var(--muted)")
 156  		dom.SetStyle(p, "fontSize", "13px")
 157  		dom.SetTextContent(p, t("no_signer_backend"))
 158  		dom.AppendChild(appSt.signerContent, p)
 159  		return
 160  	}
 161  
 162  	signer.GetVaultStatus(func(status string) {
 163  		appSt.signerStatus = status
 164  		dom.SetTextContent(appSt.signerContent, "")
 165  
 166  		if appSt.pubhex != "" {
 167  			dom.SetStyle(appSt.viewProfileRow, "display", "block")
 168  		} else {
 169  			dom.SetStyle(appSt.viewProfileRow, "display", "none")
 170  		}
 171  
 172  		switch status {
 173  		case "none":
 174  			renderCreateVault()
 175  		case "locked":
 176  			renderUnlockVault()
 177  		case "unlocked":
 178  			renderIdentityList()
 179  		}
 180  	})
 181  }
 182  
 183  func renderCreateVault() {
 184  	// "No vault found" notice.
 185  	p := dom.CreateElement("p")
 186  	dom.SetTextContent(p, t("no_vault"))
 187  	dom.SetStyle(p, "marginTop", "0")
 188  	dom.SetStyle(p, "marginBottom", "12px")
 189  	dom.AppendChild(appSt.signerContent, p)
 190  
 191  	// Single shared password field at the top.
 192  	pwInput := dom.CreateElement("input")
 193  	dom.SetAttribute(pwInput, "type", "password")
 194  	dom.SetAttribute(pwInput, "placeholder", t("vault_password"))
 195  	dom.SetAttribute(pwInput, "class", "signer-input")
 196  	dom.SetAttribute(pwInput, "autocomplete", "new-password")
 197  	dom.SetAttribute(pwInput, "autocapitalize", "off")
 198  	dom.SetAttribute(pwInput, "autocorrect", "off")
 199  	dom.SetAttribute(pwInput, "spellcheck", "false")
 200  	dom.SetStyle(pwInput, "marginBottom", "8px")
 201  	dom.AppendChild(appSt.signerContent, pwInput)
 202  
 203  	errEl := dom.CreateElement("div")
 204  	dom.SetStyle(errEl, "color", "#e55")
 205  	dom.SetStyle(errEl, "fontSize", "12px")
 206  	dom.SetStyle(errEl, "minHeight", "14px")
 207  	dom.SetStyle(errEl, "marginBottom", "4px")
 208  	dom.AppendChild(appSt.signerContent, errEl)
 209  
 210  	getPw := func() string {
 211  		pw := dom.GetProperty(pwInput, "value")
 212  		if pw == "" {
 213  			dom.SetTextContent(errEl, "password required")
 214  		} else {
 215  			dom.SetTextContent(errEl, "")
 216  		}
 217  		return pw
 218  	}
 219  
 220  	sep0 := dom.CreateElement("hr")
 221  	dom.SetAttribute(sep0, "class", "signer-sep")
 222  	dom.AppendChild(appSt.signerContent, sep0)
 223  
 224  	// --- nsec import ---
 225  	nsecInput := dom.CreateElement("input")
 226  	dom.SetAttribute(nsecInput, "type", "password")
 227  	dom.SetAttribute(nsecInput, "placeholder", "nsec1...")
 228  	dom.SetAttribute(nsecInput, "class", "signer-input")
 229  	dom.SetAttribute(nsecInput, "autocomplete", "off")
 230  	dom.SetAttribute(nsecInput, "autocapitalize", "off")
 231  	dom.SetAttribute(nsecInput, "autocorrect", "off")
 232  	dom.SetAttribute(nsecInput, "spellcheck", "false")
 233  	dom.SetStyle(nsecInput, "marginBottom", "6px")
 234  	dom.AppendChild(appSt.signerContent, nsecInput)
 235  
 236  	nsecBtn := dom.CreateElement("button")
 237  	dom.SetAttribute(nsecBtn, "class", "signer-btn")
 238  	dom.SetTextContent(nsecBtn, "create vault with nsec")
 239  	dom.SetStyle(nsecBtn, "marginTop", "0")
 240  	dom.SetStyle(nsecBtn, "marginBottom", "8px")
 241  	dom.AddEventListener(nsecBtn, "click", signerCB(func() {
 242  		nsec := dom.GetProperty(nsecInput, "value")
 243  		pw := getPw()
 244  		if len(nsec) == 0 {
 245  			dom.SetTextContent(errEl, "nsec required")
 246  			return
 247  		}
 248  		if pw == "" {
 249  			return
 250  		}
 251  		dom.SetTextContent(nsecBtn, "...")
 252  		restore := func() { dom.SetTextContent(nsecBtn, "create vault with nsec") }
 253  		addAndFinish := func() {
 254  			signer.AddIdentity(nsec, func(ok bool) {
 255  				if !ok {
 256  					dom.SetTextContent(errEl, "invalid nsec or already present")
 257  					restore()
 258  					return
 259  				}
 260  				renderSignerUI()
 261  			})
 262  		}
 263  		signer.GetVaultStatus(func(status string) {
 264  			switch status {
 265  			case "none":
 266  				signer.CreateVault(pw, func(ok bool) {
 267  					if !ok {
 268  						dom.SetTextContent(errEl, "vault create failed")
 269  						restore()
 270  						return
 271  					}
 272  					addAndFinish()
 273  				})
 274  			case "locked":
 275  				signer.UnlockVault(pw, func(ok bool) {
 276  					if !ok {
 277  						signer.LastUnlockError(func(reason string) {
 278  							msg := "wrong password"
 279  							if reason == "kdf-failed" || reason == "decrypt-failed" {
 280  								msg = "unlock failed: " | reason
 281  							}
 282  							logActivity("unlock", "FAILED: " | reason, "")
 283  							dom.SetTextContent(errEl, msg)
 284  							restore()
 285  						})
 286  						return
 287  					}
 288  					logActivity("unlock", "vault unlocked", "")
 289  					addAndFinish()
 290  				})
 291  			default:
 292  				addAndFinish()
 293  			}
 294  		})
 295  	}))
 296  	dom.AppendChild(appSt.signerContent, nsecBtn)
 297  
 298  	sep1 := dom.CreateElement("hr")
 299  	dom.SetAttribute(sep1, "class", "signer-sep")
 300  	dom.AppendChild(appSt.signerContent, sep1)
 301  
 302  	// --- HD Vault (generate) ---
 303  	h3 := dom.CreateElement("h3")
 304  	dom.SetTextContent(h3, t("hd_keychain"))
 305  	dom.AppendChild(appSt.signerContent, h3)
 306  
 307  	createBtn := dom.CreateElement("button")
 308  	dom.SetAttribute(createBtn, "class", "signer-btn")
 309  	dom.SetTextContent(createBtn, t("generate_keychain"))
 310  	createCB := signerCB(func() {
 311  		pw := getPw()
 312  		if pw == "" {
 313  			return
 314  		}
 315  		dom.SetTextContent(createBtn, t("generating"))
 316  		dom.SetAttribute(createBtn, "disabled", "true")
 317  		// Empty mnemonic -> WASM generates a fresh BIP-39 phrase.
 318  		// Passing any non-empty non-mnemonic string (e.g. a label) would be
 319  		// treated as a BIP-39 phrase and PBKDF2 a deterministic seed.
 320  		signer.CreateHDVault(pw, "", func(mnemonic string) {
 321  			if mnemonic == "" {
 322  				dom.SetTextContent(createBtn, t("create_failed"))
 323  				dom.SetAttribute(createBtn, "disabled", "")
 324  				return
 325  			}
 326  			signer.DeriveIdentity("Identity 1", func(pk string) {
 327  				showMnemonicReveal(mnemonic)
 328  			})
 329  		})
 330  	})
 331  	dom.AddEventListener(createBtn, "click", createCB)
 332  	dom.AppendChild(appSt.signerContent, createBtn)
 333  
 334  	// --- Restore from mnemonic ---
 335  	sep2 := dom.CreateElement("hr")
 336  	dom.SetAttribute(sep2, "class", "signer-sep")
 337  	dom.AppendChild(appSt.signerContent, sep2)
 338  
 339  	h3r := dom.CreateElement("h3")
 340  	dom.SetTextContent(h3r, t("restore_seed"))
 341  	dom.AppendChild(appSt.signerContent, h3r)
 342  
 343  	mnInput := dom.CreateElement("textarea")
 344  	dom.SetAttribute(mnInput, "placeholder", t("seed_placeholder"))
 345  	dom.SetAttribute(mnInput, "class", "signer-input signer-textarea")
 346  	dom.SetAttribute(mnInput, "rows", "3")
 347  	dom.AppendChild(appSt.signerContent, mnInput)
 348  
 349  	idxInput := dom.CreateElement("input")
 350  	dom.SetAttribute(idxInput, "type", "number")
 351  	dom.SetAttribute(idxInput, "placeholder", t("account_placeholder"))
 352  	dom.SetAttribute(idxInput, "class", "signer-input")
 353  	dom.SetProperty(idxInput, "value", "1")
 354  	dom.AppendChild(appSt.signerContent, idxInput)
 355  
 356  	restoreBtn := dom.CreateElement("button")
 357  	dom.SetAttribute(restoreBtn, "class", "signer-btn")
 358  	dom.SetTextContent(restoreBtn, t("restore_keychain"))
 359  	restoreCB := signerCB(func() {
 360  		mn := dom.GetProperty(mnInput, "value")
 361  		pw := getPw()
 362  		if mn == "" || pw == "" {
 363  			return
 364  		}
 365  		idxStr := dom.GetProperty(idxInput, "value")
 366  		targetIdx := parseAccountIdx(idxStr)
 367  		if targetIdx < 1 {
 368  			targetIdx = 1
 369  		}
 370  		dom.SetTextContent(restoreBtn, t("restoring"))
 371  		dom.SetAttribute(restoreBtn, "disabled", "true")
 372  		signer.RestoreHDVault(pw, mn, "Identity 0", func(ok bool) {
 373  			if !ok {
 374  				dom.SetTextContent(restoreBtn, t("restore_failed"))
 375  				dom.SetAttribute(restoreBtn, "disabled", "")
 376  				return
 377  			}
 378  			dom.SetTextContent(restoreBtn, t("deriving_n") | " " | itoa(targetIdx) | "...")
 379  			restoreDeriveUpTo(targetIdx, 1, func() {
 380  				renderSignerUI()
 381  			})
 382  		})
 383  	})
 384  	dom.AddEventListener(restoreBtn, "click", restoreCB)
 385  	dom.AppendChild(appSt.signerContent, restoreBtn)
 386  
 387  	// --- Import vault file ---
 388  	sep3 := dom.CreateElement("hr")
 389  	dom.SetAttribute(sep3, "class", "signer-sep")
 390  	dom.AppendChild(appSt.signerContent, sep3)
 391  
 392  	h3b := dom.CreateElement("h3")
 393  	dom.SetTextContent(h3b, t("import_vault"))
 394  	dom.AppendChild(appSt.signerContent, h3b)
 395  
 396  	importBtn := dom.CreateElement("button")
 397  	dom.SetAttribute(importBtn, "class", "signer-btn signer-btn-secondary")
 398  	dom.SetTextContent(importBtn, t("choose_vault"))
 399  	importCB := signerCB(func() {
 400  		dom.PickFileText(".json", func(data string) {
 401  			if data == "" {
 402  				return
 403  			}
 404  			dom.SetTextContent(importBtn, t("restoring"))
 405  			dom.SetAttribute(importBtn, "disabled", "true")
 406  			signer.ImportVault(data, func(ok bool) {
 407  				if ok {
 408  					renderSignerUI()
 409  				} else {
 410  					dom.SetTextContent(importBtn, t("invalid_vault"))
 411  					dom.SetAttribute(importBtn, "disabled", "")
 412  				}
 413  			})
 414  		})
 415  	})
 416  	dom.AddEventListener(importBtn, "click", importCB)
 417  	dom.AppendChild(appSt.signerContent, importBtn)
 418  }
 419  
 420  // showMnemonicReveal displays the generated mnemonic for the user to write down.
 421  func showMnemonicReveal(mnemonic string) {
 422  	dom.SetTextContent(appSt.signerContent, "")
 423  
 424  	h3 := dom.CreateElement("h3")
 425  	dom.SetTextContent(h3, t("write_seed"))
 426  	dom.AppendChild(appSt.signerContent, h3)
 427  
 428  	warn := dom.CreateElement("p")
 429  	dom.SetAttribute(warn, "class", "signer-warn")
 430  	dom.SetTextContent(warn, t("seed_warning"))
 431  	dom.AppendChild(appSt.signerContent, warn)
 432  
 433  	mnBox := dom.CreateElement("div")
 434  	dom.SetAttribute(mnBox, "class", "signer-mnemonic")
 435  
 436  	words := splitMnemonicWords(mnemonic)
 437  	for i, w := range words {
 438  		span := dom.CreateElement("span")
 439  		dom.SetAttribute(span, "class", "signer-word")
 440  		dom.SetTextContent(span, itoa(i+1) | ". " | w)
 441  		dom.AppendChild(mnBox, span)
 442  	}
 443  	dom.AppendChild(appSt.signerContent, mnBox)
 444  
 445  	copyBtn := dom.CreateElement("button")
 446  	dom.SetAttribute(copyBtn, "class", "signer-btn signer-btn-secondary")
 447  	dom.SetAttribute(copyBtn, "data-mn", mnemonic)
 448  	dom.SetTextContent(copyBtn, t("copy_clipboard"))
 449  	dom.SetAttribute(copyBtn, "data-label", t("copy_clipboard"))
 450  	dom.SetAttribute(copyBtn, "data-copied", t("copied"))
 451  	dom.SetAttribute(copyBtn, "onclick", "var b=this;navigator.clipboard.writeText(b.dataset.mn).then(function(){b.textContent=b.dataset.copied});setTimeout(function(){b.textContent=b.dataset.label},1500)")
 452  	dom.AppendChild(appSt.signerContent, copyBtn)
 453  
 454  	doneBtn := dom.CreateElement("button")
 455  	dom.SetAttribute(doneBtn, "class", "signer-btn")
 456  	dom.SetTextContent(doneBtn, t("saved_it"))
 457  	doneCB := signerCB(func() {
 458  		renderSignerUI()
 459  	})
 460  	dom.AddEventListener(doneBtn, "click", doneCB)
 461  	dom.AppendChild(appSt.signerContent, doneBtn)
 462  }
 463  
 464  func splitMnemonicWords(s string) (ss []string) {
 465  	var words []string
 466  	start := -1
 467  	for i := 0; i < len(s); i++ {
 468  		if s[i] == ' ' {
 469  			if start >= 0 {
 470  				words = push(words, s[start:i])
 471  				start = -1
 472  			}
 473  		} else if start < 0 {
 474  			start = i
 475  		}
 476  	}
 477  	if start >= 0 {
 478  		words = push(words, s[start:])
 479  	}
 480  	return words
 481  }
 482  
 483  
 484  func renderUnlockVault() {
 485  	p := dom.CreateElement("p")
 486  	dom.SetTextContent(p, t("vault_locked"))
 487  	dom.SetStyle(p, "marginBottom", "12px")
 488  	dom.AppendChild(appSt.signerContent, p)
 489  
 490  	input := dom.CreateElement("input")
 491  	dom.SetAttribute(input, "type", "password")
 492  	dom.SetAttribute(input, "placeholder", t("password"))
 493  	dom.SetAttribute(input, "class", "signer-input")
 494  	dom.SetAttribute(input, "autocomplete", "current-password")
 495  	dom.SetAttribute(input, "autocapitalize", "off")
 496  	dom.SetAttribute(input, "autocorrect", "off")
 497  	dom.SetAttribute(input, "spellcheck", "false")
 498  	dom.AppendChild(appSt.signerContent, input)
 499  	dom.Focus(input)
 500  
 501  	btn := dom.CreateElement("button")
 502  	dom.SetAttribute(btn, "class", "signer-btn")
 503  	dom.SetTextContent(btn, t("unlock"))
 504  	cb := signerCB(func() {
 505  		pw := dom.GetProperty(input, "value")
 506  		if pw == "" {
 507  			return
 508  		}
 509  		dom.SetTextContent(p, t("deriving_key"))
 510  		dom.SetAttribute(btn, "disabled", "true")
 511  		logActivity("unlock", "deriving key from password", "")
 512  		signer.UnlockVault(pw, func(ok bool) {
 513  			if ok {
 514  				logActivity("unlock", "vault unlocked", "")
 515  				renderSignerUI()
 516  				return
 517  			}
 518  			signer.LastUnlockError(func(reason string) {
 519  				if reason == "" {
 520  					reason = "unknown"
 521  				}
 522  				logActivity("unlock", "FAILED: " | reason, "reasons:\n  wrong-password = sha256(password) != stored hash\n  kdf-failed = Argon2id returned empty (memory/GC issue on mobile)\n  decrypt-failed = key derived but AES-GCM decryption failed (corrupted vault or non-deterministic argon2)\n  no-vault = vault data missing\n  no-hash = vault missing hash field\n  bad-iv = legacy IV invalid\n\nFor wrong-password, the entry includes computed= and stored= prefixes:\n  - if they're stable across attempts but differ: stored hash was corrupted on save (likely a bug)\n  - if computed differs across attempts: SHA256 is non-deterministic (memory corruption)\n  - if same as last attempt: real typo")
 523  				msg := "wrong_password"
 524  				if reason == "kdf-failed" || (len(reason) >= 14 && reason[:14] == "decrypt-failed") {
 525  					msg = "unlock_failed_kdf"
 526  				}
 527  				dom.SetTextContent(p, t(msg))
 528  				dom.SetAttribute(btn, "disabled", "")
 529  			})
 530  		})
 531  	})
 532  	dom.AddEventListener(btn, "click", cb)
 533  	dom.AddEnterKeyListener(input, cb)
 534  	dom.AppendChild(appSt.signerContent, btn)
 535  
 536  	// Reset button - lets user start over without digging into settings.
 537  	resetBtn := dom.CreateElement("button")
 538  	dom.SetAttribute(resetBtn, "class", "signer-btn")
 539  	dom.SetStyle(resetBtn, "background", "transparent")
 540  	dom.SetStyle(resetBtn, "color", "var(--muted)")
 541  	dom.SetStyle(resetBtn, "border", "1px solid var(--border)")
 542  	dom.SetStyle(resetBtn, "marginTop", "12px")
 543  	dom.SetTextContent(resetBtn, t("logout"))
 544  	dom.AddEventListener(resetBtn, "click", signerCB(func() {
 545  		if !dom.Confirm(t("logout_confirm")) {
 546  			return
 547  		}
 548  		signer.ResetExtension(func(ok bool) {
 549  			localstorage.RemoveItem(lsKeyPubkey)
 550  			dom.LocationAssign("/")
 551  		})
 552  	}))
 553  	dom.AppendChild(appSt.signerContent, resetBtn)
 554  }
 555  
 556  func renderIdentityList() {
 557  	signer.ListIdentities(func(list string) {
 558  		signer.IsHD(func(hd bool) {
 559  			dom.SetTextContent(appSt.signerContent, "")
 560  
 561  			h := dom.CreateElement("h3")
 562  			dom.SetTextContent(h, t("identities"))
 563  			dom.AppendChild(appSt.signerContent, h)
 564  
 565  			renderIdentitiesFromJSON(list, hd)
 566  
 567  			if hd {
 568  				renderHDControls()
 569  			}
 570  			renderLegacyAddIdentity()
 571  			renderBottomActions(hd)
 572  		})
 573  	})
 574  }
 575  
 576  func renderHDControls() {
 577  	addDiv := dom.CreateElement("div")
 578  	dom.SetAttribute(addDiv, "class", "signer-add")
 579  
 580  	nameInput := dom.CreateElement("input")
 581  	dom.SetAttribute(nameInput, "type", "text")
 582  	dom.SetAttribute(nameInput, "placeholder", t("nickname_placeholder"))
 583  	dom.SetAttribute(nameInput, "class", "signer-input")
 584  	dom.AppendChild(addDiv, nameInput)
 585  
 586  	deriveBtn := dom.CreateElement("button")
 587  	dom.SetAttribute(deriveBtn, "class", "signer-btn")
 588  	dom.SetTextContent(deriveBtn, t("derive_new"))
 589  	deriveCB := signerCB(func() {
 590  		name := dom.GetProperty(nameInput, "value")
 591  		dom.SetAttribute(deriveBtn, "disabled", "true")
 592  		dom.SetTextContent(deriveBtn, t("deriving"))
 593  		signer.DeriveIdentity(name, func(pk string) {
 594  			if pk == "" {
 595  				dom.SetAttribute(deriveBtn, "disabled", "")
 596  				dom.SetTextContent(deriveBtn, t("derive_new"))
 597  				return
 598  			}
 599  			// Stash name - kind 0 will be published after login when relays are live.
 600  			appSt.pendingK0Name = name
 601  			renderSignerUI()
 602  		})
 603  	})
 604  	dom.AddEventListener(deriveBtn, "click", deriveCB)
 605  	dom.AppendChild(addDiv, deriveBtn)
 606  	dom.AppendChild(appSt.signerContent, addDiv)
 607  }
 608  
 609  func parseAccountIdx(s string) (n int32) {
 610  	n = 0
 611  	for i := 0; i < len(s); i++ {
 612  		c := s[i]
 613  		if c >= '0' && c <= '9' {
 614  			n = n*10 + int32(c-'0')
 615  		} else {
 616  			break
 617  		}
 618  	}
 619  	return n
 620  }
 621  
 622  // restoreDeriveUpTo derives accounts from cur up to target sequentially.
 623  func restoreDeriveUpTo(target, cur int32, done func()) {
 624  	if cur > target {
 625  		done()
 626  		return
 627  	}
 628  	signer.DeriveIdentity("Identity " | itoa(cur), func(pk string) {
 629  		restoreDeriveUpTo(target, cur+1, done)
 630  	})
 631  }
 632  
 633  // flushPendingK0 publishes a kind 0 if one was queued during identity derivation.
 634  // Called from showApp() after relays are live.
 635  func flushPendingK0() {
 636  	name := appSt.pendingK0Name
 637  	if name == "" || appSt.pubhex == "" {
 638  		return
 639  	}
 640  	appSt.pendingK0Name = ""
 641  	publishKind0ForIdentity(appSt.pubhex, name, func() {})
 642  }
 643  
 644  
 645  // publishKind0ForIdentity switches to the given identity, signs a kind 0 event
 646  // with the nickname, publishes it to all relays, then calls done.
 647  func publishKind0ForIdentity(pk, name string, done func()) {
 648  	signer.SwitchIdentity(pk, func(ok bool) {
 649  		if !ok {
 650  			done()
 651  			return
 652  		}
 653  		content := "{\"name\":" | helpers.JsonString(name) | ",\"display_name\":" | helpers.JsonString(name) | "}"
 654  		ts := dom.NowSeconds()
 655  		unsigned := "{\"kind\":0,\"content\":" | helpers.JsonString(content) |
 656  			",\"tags\":[],\"created_at\":" | i64toa(ts) |
 657  			",\"pubkey\":\"" | pk | "\"}"
 658  		dom.ConsoleLog("[signer-panel] signing kind 0 for " | pk[:8])
 659  		signer.SignEvent(unsigned, func(signed string) {
 660  			if len(signed) > 0 {
 661  				dom.PostToSW("[\"EVENT\"," | signed | "]")
 662  				dom.ConsoleLog("[signer-panel] published kind 0 for " | pk[:8] | " name=" | name)
 663  			} else {
 664  				dom.ConsoleLog("[signer-panel] sign failed for " | pk[:8] | " - check console for [signer] errors")
 665  			}
 666  			done()
 667  		})
 668  	})
 669  }
 670  
 671  func renderLegacyAddIdentity() {
 672  	addDiv := dom.CreateElement("div")
 673  	dom.SetAttribute(addDiv, "class", "signer-add")
 674  
 675  	addInput := dom.CreateElement("input")
 676  	dom.SetAttribute(addInput, "type", "password")
 677  	dom.SetAttribute(addInput, "placeholder", "nsec1...")
 678  	dom.SetAttribute(addInput, "class", "signer-input")
 679  	dom.SetAttribute(addInput, "autocomplete", "off")
 680  	dom.SetAttribute(addInput, "autocapitalize", "off")
 681  	dom.SetAttribute(addInput, "autocorrect", "off")
 682  	dom.SetAttribute(addInput, "spellcheck", "false")
 683  	dom.AppendChild(addDiv, addInput)
 684  
 685  	addErr := dom.CreateElement("div")
 686  	dom.SetStyle(addErr, "color", "#e55")
 687  	dom.SetStyle(addErr, "fontSize", "12px")
 688  	dom.SetStyle(addErr, "minHeight", "14px")
 689  	dom.SetStyle(addErr, "marginBottom", "4px")
 690  	dom.AppendChild(addDiv, addErr)
 691  
 692  	addBtn := dom.CreateElement("button")
 693  	dom.SetAttribute(addBtn, "class", "signer-btn")
 694  	dom.SetTextContent(addBtn, t("add"))
 695  	addCB := signerCB(func() {
 696  		nsec := dom.GetProperty(addInput, "value")
 697  		if nsec == "" {
 698  			dom.SetTextContent(addErr, "nsec required")
 699  			return
 700  		}
 701  		dom.SetTextContent(addErr, "")
 702  		signer.AddIdentity(nsec, func(ok bool) {
 703  			if ok {
 704  				dom.SetProperty(addInput, "value", "")
 705  				renderSignerUI()
 706  				return
 707  			}
 708  			dom.SetTextContent(addErr, "invalid nsec or already present")
 709  		})
 710  	})
 711  	dom.AddEventListener(addBtn, "click", addCB)
 712  	dom.AppendChild(addDiv, addBtn)
 713  	dom.AppendChild(appSt.signerContent, addDiv)
 714  }
 715  
 716  func renderBottomActions(hd bool) {
 717  	actions := dom.CreateElement("div")
 718  	dom.SetAttribute(actions, "class", "signer-actions")
 719  
 720  	// Show seed phrase button (HD only).
 721  	if hd {
 722  		seedBtn := dom.CreateElement("button")
 723  		dom.SetAttribute(seedBtn, "class", "signer-btn signer-btn-secondary")
 724  		dom.SetTextContent(seedBtn, t("show_seed"))
 725  		seedCB := signerCB(func() {
 726  			signer.GetMnemonic(func(m string) {
 727  				if m != "" {
 728  					showMnemonicReveal(m)
 729  				}
 730  			})
 731  		})
 732  		dom.AddEventListener(seedBtn, "click", seedCB)
 733  		dom.AppendChild(actions, seedBtn)
 734  	}
 735  
 736  	// Export vault button.
 737  	exportBtn := dom.CreateElement("button")
 738  	dom.SetAttribute(exportBtn, "class", "signer-btn signer-btn-secondary")
 739  	dom.SetTextContent(exportBtn, t("export_vault"))
 740  	exportCB := signerCB(func() {
 741  		showExportPasswordPrompt()
 742  	})
 743  	dom.AddEventListener(exportBtn, "click", exportCB)
 744  	dom.AppendChild(actions, exportBtn)
 745  
 746  	// Add HD keychain - navigates to the create-new-HD-keychain flow.
 747  	addHDBtn := dom.CreateElement("button")
 748  	dom.SetAttribute(addHDBtn, "class", "signer-btn signer-btn-secondary")
 749  	dom.SetTextContent(addHDBtn, "Add HD keychain")
 750  	addHDCB := signerCB(func() {
 751  		dom.SetTextContent(appSt.signerContent, "")
 752  		renderCreateVault()
 753  	})
 754  	dom.AddEventListener(addHDBtn, "click", addHDCB)
 755  	dom.AppendChild(actions, addHDBtn)
 756  
 757  	// Lock vault button.
 758  	lockBtn := dom.CreateElement("button")
 759  	dom.SetAttribute(lockBtn, "class", "signer-btn signer-btn-secondary")
 760  	dom.SetTextContent(lockBtn, t("lock_vault"))
 761  	lockCB := signerCB(func() {
 762  		signer.LockVault(func() {
 763  			renderSignerUI()
 764  		})
 765  	})
 766  	dom.AddEventListener(lockBtn, "click", lockCB)
 767  	dom.AppendChild(actions, lockBtn)
 768  
 769  	// Logout (wipes the vault and reloads).
 770  	resetBtn := dom.CreateElement("button")
 771  	dom.SetAttribute(resetBtn, "class", "signer-btn signer-btn-danger")
 772  	dom.SetTextContent(resetBtn, t("logout"))
 773  	resetCB := signerCB(func() {
 774  		if !dom.Confirm(t("logout_confirm")) {
 775  			return
 776  		}
 777  		signer.ResetExtension(func(ok bool) {
 778  			localstorage.RemoveItem(lsKeyPubkey)
 779  			dom.LocationAssign("/")
 780  		})
 781  	})
 782  	dom.AddEventListener(resetBtn, "click", resetCB)
 783  	dom.AppendChild(actions, resetBtn)
 784  
 785  	dom.AppendChild(appSt.signerContent, actions)
 786  }
 787  
 788  func showExportPasswordPrompt() {
 789  	// Replace signer content with password prompt.
 790  	dom.SetTextContent(appSt.signerContent, "")
 791  
 792  	label := dom.CreateElement("p")
 793  	dom.SetTextContent(label, t("export_password_prompt"))
 794  	dom.SetStyle(label, "marginBottom", "8px")
 795  	dom.AppendChild(appSt.signerContent, label)
 796  
 797  	pwInput := dom.CreateElement("input")
 798  	dom.SetAttribute(pwInput, "type", "password")
 799  	dom.SetAttribute(pwInput, "placeholder", t("password"))
 800  	dom.SetAttribute(pwInput, "class", "signer-input")
 801  	dom.SetAttribute(pwInput, "autocomplete", "new-password")
 802  	dom.SetAttribute(pwInput, "autocapitalize", "off")
 803  	dom.SetAttribute(pwInput, "autocorrect", "off")
 804  	dom.SetAttribute(pwInput, "spellcheck", "false")
 805  	dom.AppendChild(appSt.signerContent, pwInput)
 806  
 807  	confirmInput := dom.CreateElement("input")
 808  	dom.SetAttribute(confirmInput, "type", "password")
 809  	dom.SetAttribute(confirmInput, "placeholder", t("confirm_password"))
 810  	dom.SetAttribute(confirmInput, "class", "signer-input")
 811  	dom.SetAttribute(confirmInput, "autocomplete", "new-password")
 812  	dom.SetAttribute(confirmInput, "autocapitalize", "off")
 813  	dom.SetAttribute(confirmInput, "autocorrect", "off")
 814  	dom.SetAttribute(confirmInput, "spellcheck", "false")
 815  	dom.SetStyle(confirmInput, "marginTop", "8px")
 816  	dom.AppendChild(appSt.signerContent, confirmInput)
 817  
 818  	errEl := dom.CreateElement("p")
 819  	dom.SetStyle(errEl, "color", "var(--error, #e55)")
 820  	dom.SetStyle(errEl, "fontSize", "13px")
 821  	dom.SetStyle(errEl, "marginTop", "4px")
 822  	dom.AppendChild(appSt.signerContent, errEl)
 823  
 824  	btnRow := dom.CreateElement("div")
 825  	dom.SetStyle(btnRow, "display", "flex")
 826  	dom.SetStyle(btnRow, "gap", "8px")
 827  	dom.SetStyle(btnRow, "marginTop", "12px")
 828  
 829  	cancelBtn := dom.CreateElement("button")
 830  	dom.SetAttribute(cancelBtn, "class", "signer-btn signer-btn-secondary")
 831  	dom.SetTextContent(cancelBtn, t("cancel"))
 832  	dom.AddEventListener(cancelBtn, "click", signerCB(func() {
 833  		renderSignerUI()
 834  	}))
 835  	dom.AppendChild(btnRow, cancelBtn)
 836  
 837  	goBtn := dom.CreateElement("button")
 838  	dom.SetAttribute(goBtn, "class", "signer-btn signer-btn-primary")
 839  	dom.SetTextContent(goBtn, t("export_vault"))
 840  	dom.AddEventListener(goBtn, "click", signerCB(func() {
 841  		pw := dom.GetProperty(pwInput, "value")
 842  		confirm := dom.GetProperty(confirmInput, "value")
 843  		if pw == "" {
 844  			dom.SetTextContent(errEl, t("password_required"))
 845  			return
 846  		}
 847  		if pw != confirm {
 848  			dom.SetTextContent(errEl, t("passwords_mismatch"))
 849  			return
 850  		}
 851  		dom.SetTextContent(goBtn, "...")
 852  		signer.ExportVault(pw, func(data string) {
 853  			if data == "" {
 854  				dom.SetTextContent(errEl, t("export_failed"))
 855  				dom.SetTextContent(goBtn, t("export_vault"))
 856  				return
 857  			}
 858  			dom.DownloadText("musiquay-vault.json", data, "application/json")
 859  			renderSignerUI()
 860  		})
 861  	}))
 862  	dom.AppendChild(btnRow, goBtn)
 863  	dom.AppendChild(appSt.signerContent, btnRow)
 864  
 865  	dom.Focus(pwInput)
 866  }
 867  
 868  func renderIdentitiesFromJSON(listJSON string, hd bool) {
 869  	i := 0
 870  	idxNum := 0
 871  	for i < len(listJSON) && listJSON[i] != '[' {
 872  		i++
 873  	}
 874  	i++
 875  	for i < len(listJSON) {
 876  		for i < len(listJSON) && listJSON[i] != '{' && listJSON[i] != ']' {
 877  			i++
 878  		}
 879  		if i >= len(listJSON) || listJSON[i] == ']' {
 880  			break
 881  		}
 882  		end := i + 1
 883  		depth := 1
 884  		for end < len(listJSON) && depth > 0 {
 885  			if listJSON[end] == '{' {
 886  				depth++
 887  			} else if listJSON[end] == '}' {
 888  				depth--
 889  			} else if listJSON[end] == '"' {
 890  				end++
 891  				for end < len(listJSON) && listJSON[end] != '"' {
 892  					if listJSON[end] == '\\' {
 893  						end++
 894  					}
 895  					end++
 896  				}
 897  			}
 898  			end++
 899  		}
 900  		obj := listJSON[i:end]
 901  		pk := helpers.JsonGetString(obj, "pubkey")
 902  		name := helpers.JsonGetString(obj, "name")
 903  		if pk == "" {
 904  			i = end
 905  			idxNum++
 906  			continue
 907  		}
 908  
 909  		row := dom.CreateElement("div")
 910  		dom.SetAttribute(row, "class", "signer-identity")
 911  
 912  		label := dom.CreateElement("span")
 913  		display := pk[:8] | "..."
 914  		if name != "" {
 915  			display = name | " (" | pk[:8] | "...)"
 916  		}
 917  		if hd {
 918  			display = "#" | itoa(idxNum) | " " | display
 919  		}
 920  		dom.SetTextContent(label, display)
 921  		dom.AppendChild(row, label)
 922  
 923  		btns := dom.CreateElement("span")
 924  
 925  		// HD identity 0 is the seed - skip it entirely.
 926  		if hd && idxNum == 0 {
 927  			i = end
 928  			idxNum++
 929  			continue
 930  		}
 931  
 932  		// Switch / current-identity indicator.
 933  		if appSt.pubhex != "" && pk == appSt.pubhex {
 934  			loggedIn := dom.CreateElement("span")
 935  			dom.SetAttribute(loggedIn, "class", "signer-btn-sm")
 936  			dom.SetStyle(loggedIn, "color", "var(--muted)")
 937  			dom.SetStyle(loggedIn, "opacity", "0.6")
 938  			dom.SetStyle(loggedIn, "cursor", "default")
 939  			dom.SetStyle(loggedIn, "pointerEvents", "none")
 940  			dom.SetTextContent(loggedIn, "Logged in")
 941  			dom.AppendChild(btns, loggedIn)
 942  		} else {
 943  			switchBtn := dom.CreateElement("button")
 944  			dom.SetAttribute(switchBtn, "class", "signer-btn-sm")
 945  			dom.SetStyle(switchBtn, "color", "var(--accent)")
 946  			dom.SetStyle(switchBtn, "fontWeight", "bold")
 947  			dom.SetTextContent(switchBtn, "Login")
 948  			switchPK := pk
 949  			switchCB := signerCB(func() {
 950  				signer.SwitchIdentity(switchPK, func(ok bool) {
 951  					if ok {
 952  						hideSignerPanel()
 953  						appSt.pubhex = switchPK
 954  						localstorage.SetItem(lsKeyPubkey, appSt.pubhex)
 955  						// Reset timestamp guards so the new identity's
 956  						// replaceable events aren't rejected as stale.
 957  						appSt.profileTs = 0
 958  						appSt.contactTs = 0
 959  						appSt.muteTs = 0
 960  						appSt.relayListTs = 0
 961  						appSt.inboxTs = 0
 962  						appSt.feedSubscribed = false
 963  						clearChildren(appSt.root)
 964  						bootstrapEncKey(func() {
 965  							showApp()
 966  						})
 967  					}
 968  				})
 969  			})
 970  			dom.AddEventListener(switchBtn, "click", switchCB)
 971  			dom.AppendChild(btns, switchBtn)
 972  		}
 973  
 974  		// Publish kind 0 button.
 975  		pubBtn := dom.CreateElement("button")
 976  		dom.SetAttribute(pubBtn, "class", "signer-btn-sm")
 977  		dom.SetTextContent(pubBtn, t("publish"))
 978  		pubPK := pk
 979  		pubName := name
 980  		pubCB := signerCB(func() {
 981  			dom.SetTextContent(pubBtn, "...")
 982  			dom.SetAttribute(pubBtn, "disabled", "true")
 983  			publishKind0ForIdentity(pubPK, pubName, func() {
 984  				dom.SetTextContent(pubBtn, t("publish"))
 985  				dom.SetAttribute(pubBtn, "disabled", "")
 986  			})
 987  		})
 988  		dom.AddEventListener(pubBtn, "click", pubCB)
 989  		dom.AppendChild(btns, pubBtn)
 990  
 991  		// Remove button.
 992  		rmBtn := dom.CreateElement("button")
 993  		dom.SetAttribute(rmBtn, "class", "signer-btn-sm signer-btn-danger")
 994  		dom.SetTextContent(rmBtn, "\u00d7")
 995  		rmPK := pk
 996  		rmCB := signerCB(func() {
 997  			signer.RemoveIdentity(rmPK, func(ok bool) {
 998  				if ok {
 999  					renderSignerUI()
1000  				}
1001  			})
1002  		})
1003  		dom.AddEventListener(rmBtn, "click", rmCB)
1004  		dom.AppendChild(btns, rmBtn)
1005  
1006  		dom.AppendChild(row, btns)
1007  		dom.AppendChild(appSt.signerContent, row)
1008  		i = end
1009  		idxNum++
1010  	}
1011  }
1012