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