signer.mx raw

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